diff --git a/app/database/entity/master_modules.entity.go b/app/database/entity/master_modules.entity.go index 4eedc12..b11e79c 100644 --- a/app/database/entity/master_modules.entity.go +++ b/app/database/entity/master_modules.entity.go @@ -10,6 +10,7 @@ type MasterModules struct { Name string `json:"name" gorm:"type:varchar"` Description string `json:"description" gorm:"type:varchar"` PathUrl string `json:"path_url" gorm:"type:varchar"` + ActionType *string `json:"action_type" gorm:"type:varchar"` // view, create, edit, delete, approve, export, etc StatusId int `json:"status_id" gorm:"type:int4"` ClientId *uuid.UUID `json:"client_id" gorm:"type:UUID"` IsActive *bool `json:"is_active" gorm:"type:bool;default:true"` diff --git a/app/database/entity/menu_modules.entity.go b/app/database/entity/menu_modules.entity.go new file mode 100644 index 0000000..0c9e25d --- /dev/null +++ b/app/database/entity/menu_modules.entity.go @@ -0,0 +1,24 @@ +package entity + +import ( + "github.com/google/uuid" + "time" +) + +// MenuModules menghubungkan menu dengan modul-modul yang dimilikinya +// Contoh: Menu "Article" bisa punya modul: "table_article", "create_article", "edit_article", "delete_article" +type MenuModules struct { + ID uint `json:"id" gorm:"primaryKey;type:int4;autoIncrement"` + MenuId uint `json:"menu_id" gorm:"type:int4;not null"` + ModuleId uint `json:"module_id" gorm:"type:int4;not null"` + Position *int `json:"position" gorm:"type:int4"` // Urutan modul dalam menu + ClientId *uuid.UUID `json:"client_id" gorm:"type:UUID"` + IsActive *bool `json:"is_active" gorm:"type:bool;default:true"` + CreatedAt time.Time `json:"created_at" gorm:"default:now()"` + UpdatedAt time.Time `json:"updated_at" gorm:"default:now()"` + + // Relations + Menu *MasterMenus `json:"menu,omitempty" gorm:"foreignKey:MenuId"` + Module *MasterModules `json:"module,omitempty" gorm:"foreignKey:ModuleId"` +} + diff --git a/app/database/entity/user_level_module_accesses.entity.go b/app/database/entity/user_level_module_accesses.entity.go new file mode 100644 index 0000000..a6cb469 --- /dev/null +++ b/app/database/entity/user_level_module_accesses.entity.go @@ -0,0 +1,24 @@ +package entity + +import ( + "github.com/google/uuid" + "time" +) + +// UserLevelModuleAccesses mengatur akses user_level ke modul-modul tertentu +// Contoh: UserLevel "Admin Pusat" bisa akses semua modul, "Editor" hanya bisa akses "create_article" dan "edit_article" +type UserLevelModuleAccesses struct { + ID uint `json:"id" gorm:"primaryKey;type:int4;autoIncrement"` + UserLevelId uint `json:"user_level_id" gorm:"type:int4;not null"` + ModuleId uint `json:"module_id" gorm:"type:int4;not null"` + CanAccess bool `json:"can_access" gorm:"type:bool;default:true"` // Apakah boleh akses modul ini + ClientId *uuid.UUID `json:"client_id" gorm:"type:UUID"` + IsActive *bool `json:"is_active" gorm:"type:bool;default:true"` + CreatedAt time.Time `json:"created_at" gorm:"default:now()"` + UpdatedAt time.Time `json:"updated_at" gorm:"default:now()"` + + // Relations + UserLevel *UserLevels `json:"user_level,omitempty" gorm:"foreignKey:UserLevelId"` + Module *MasterModules `json:"module,omitempty" gorm:"foreignKey:ModuleId"` +} + diff --git a/app/middleware/module_access.middleware.go b/app/middleware/module_access.middleware.go new file mode 100644 index 0000000..8213f4f --- /dev/null +++ b/app/middleware/module_access.middleware.go @@ -0,0 +1,295 @@ +package middleware + +import ( + "netidhub-saas-be/app/database" + "netidhub-saas-be/app/database/entity" + + "github.com/gofiber/fiber/v2" +) + +type ModuleAccessMiddleware struct { + DB *database.Database +} + +func NewModuleAccessMiddleware(db *database.Database) *ModuleAccessMiddleware { + return &ModuleAccessMiddleware{ + DB: db, + } +} + +// CheckModuleAccess middleware untuk validasi akses user_level ke modul tertentu +// Menggunakan module_id atau path_url sebagai identifier +func (m *ModuleAccessMiddleware) CheckModuleAccess(moduleIdentifier interface{}) fiber.Handler { + return func(c *fiber.Ctx) error { + // Get user from context (diasumsikan sudah ada middleware auth yang set user ke context) + userCtx := c.Locals("user") + if userCtx == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "code": 401, + "messages": []string{"User tidak terautentikasi"}, + }) + } + + // Cast user dari context + user, ok := userCtx.(*entity.Users) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "code": 401, + "messages": []string{"User tidak valid"}, + }) + } + + // Get user role untuk mendapatkan user_level_id + var userRole entity.UserRoles + if err := m.DB.DB.Where("id = ?", user.UserRoleId).First(&userRole).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "code": 500, + "messages": []string{"Error mendapatkan user role"}, + "error": err.Error(), + }) + } + + userLevelId := userRole.UserLevelId + + // Dapatkan module berdasarkan identifier (bisa module_id atau path_url) + var module entity.MasterModules + var err error + + switch v := moduleIdentifier.(type) { + case uint: + // Jika moduleIdentifier adalah ID + err = m.DB.DB.Where("id = ? AND is_active = ?", v, true).First(&module).Error + case string: + // Jika moduleIdentifier adalah path_url + err = m.DB.DB.Where("path_url = ? AND is_active = ?", v, true).First(&module).Error + default: + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "code": 400, + "messages": []string{"Module identifier tidak valid"}, + }) + } + + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "success": false, + "code": 404, + "messages": []string{"Module tidak ditemukan"}, + }) + } + + // Check akses user_level ke module + var access entity.UserLevelModuleAccesses + err = m.DB.DB.Where( + "user_level_id = ? AND module_id = ? AND is_active = ?", + userLevelId, + module.ID, + true, + ).First(&access).Error + + if err != nil { + // Jika tidak ada record, berarti tidak ada akses + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "success": false, + "code": 403, + "messages": []string{"Anda tidak memiliki akses ke modul ini"}, + "user_level_id": userLevelId, + "module_id": module.ID, + "module_name": module.Name, + }) + } + + if !access.CanAccess { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "success": false, + "code": 403, + "messages": []string{"Akses ke modul ini ditolak"}, + "user_level_id": userLevelId, + "module_id": module.ID, + "module_name": module.Name, + }) + } + + // Set module ke context untuk digunakan di handler + c.Locals("module", &module) + c.Locals("user_level_id", userLevelId) + + return c.Next() + } +} + +// CheckModuleAccessByPath middleware untuk validasi akses berdasarkan path yang sedang diakses +// Akan otomatis mencocokkan path dengan module.path_url +func (m *ModuleAccessMiddleware) CheckModuleAccessByPath() fiber.Handler { + return func(c *fiber.Ctx) error { + // Get user from context + userCtx := c.Locals("user") + if userCtx == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "code": 401, + "messages": []string{"User tidak terautentikasi"}, + }) + } + + user, ok := userCtx.(*entity.Users) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "code": 401, + "messages": []string{"User tidak valid"}, + }) + } + + // Get user role untuk mendapatkan user_level_id + var userRole entity.UserRoles + if err := m.DB.DB.Where("id = ?", user.UserRoleId).First(&userRole).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "code": 500, + "messages": []string{"Error mendapatkan user role"}, + "error": err.Error(), + }) + } + + userLevelId := userRole.UserLevelId + currentPath := c.Path() + + // Cari module berdasarkan path_url yang cocok + var module entity.MasterModules + err := m.DB.DB.Where("path_url = ? AND is_active = ?", currentPath, true).First(&module).Error + + if err != nil { + // Jika module tidak ditemukan, bisa jadi path ini tidak perlu validasi modul + // Atau bisa langsung return error tergantung kebijakan + return c.Next() // Skip validation jika module tidak ditemukan + } + + // Check akses user_level ke module + var access entity.UserLevelModuleAccesses + err = m.DB.DB.Where( + "user_level_id = ? AND module_id = ? AND is_active = ?", + userLevelId, + module.ID, + true, + ).First(&access).Error + + if err != nil || !access.CanAccess { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "success": false, + "code": 403, + "messages": []string{"Anda tidak memiliki akses ke halaman ini"}, + "user_level_id": userLevelId, + "path": currentPath, + "module_name": module.Name, + }) + } + + // Set module ke context + c.Locals("module", &module) + c.Locals("user_level_id", userLevelId) + + return c.Next() + } +} + +// CheckMenuAccess middleware untuk validasi akses user_level ke menu beserta modul-modulnya +func (m *ModuleAccessMiddleware) CheckMenuAccess(menuId uint) fiber.Handler { + return func(c *fiber.Ctx) error { + // Get user from context + userCtx := c.Locals("user") + if userCtx == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "code": 401, + "messages": []string{"User tidak terautentikasi"}, + }) + } + + user, ok := userCtx.(*entity.Users) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "code": 401, + "messages": []string{"User tidak valid"}, + }) + } + + // Get user role untuk mendapatkan user_level_id + var userRole entity.UserRoles + if err := m.DB.DB.Where("id = ?", user.UserRoleId).First(&userRole).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "code": 500, + "messages": []string{"Error mendapatkan user role"}, + "error": err.Error(), + }) + } + + userLevelId := userRole.UserLevelId + + // Get menu + var menu entity.MasterMenus + if err := m.DB.DB.Where("id = ? AND is_active = ?", menuId, true).First(&menu).Error; err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "success": false, + "code": 404, + "messages": []string{"Menu tidak ditemukan"}, + }) + } + + // Get semua modul yang ada di menu ini + var menuModules []entity.MenuModules + if err := m.DB.DB.Where("menu_id = ? AND is_active = ?", menuId, true).Find(&menuModules).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "code": 500, + "messages": []string{"Error mendapatkan menu modules"}, + "error": err.Error(), + }) + } + + if len(menuModules) == 0 { + // Jika menu tidak punya modul, skip validasi + return c.Next() + } + + // Check apakah user_level memiliki akses ke minimal satu modul di menu ini + hasAccess := false + for _, menuModule := range menuModules { + var access entity.UserLevelModuleAccesses + err := m.DB.DB.Where( + "user_level_id = ? AND module_id = ? AND is_active = ? AND can_access = ?", + userLevelId, + menuModule.ModuleId, + true, + true, + ).First(&access).Error + + if err == nil { + hasAccess = true + break + } + } + + if !hasAccess { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "success": false, + "code": 403, + "messages": []string{"Anda tidak memiliki akses ke menu ini"}, + "user_level_id": userLevelId, + "menu_id": menuId, + "menu_name": menu.Name, + }) + } + + // Set menu ke context + c.Locals("menu", &menu) + c.Locals("user_level_id", userLevelId) + + return c.Next() + } +} diff --git a/app/module/menu_modules/controller/controller.go b/app/module/menu_modules/controller/controller.go new file mode 100644 index 0000000..9f25e19 --- /dev/null +++ b/app/module/menu_modules/controller/controller.go @@ -0,0 +1,14 @@ +package controller + +import "netidhub-saas-be/app/module/menu_modules/service" + +type Controller struct { + MenuModules MenuModulesController +} + +func NewController(menuModulesService service.MenuModulesService) *Controller { + return &Controller{ + MenuModules: NewMenuModulesController(menuModulesService), + } +} + diff --git a/app/module/menu_modules/controller/menu_modules.controller.go b/app/module/menu_modules/controller/menu_modules.controller.go new file mode 100644 index 0000000..306d5b6 --- /dev/null +++ b/app/module/menu_modules/controller/menu_modules.controller.go @@ -0,0 +1,293 @@ +package controller + +import ( + "netidhub-saas-be/app/module/menu_modules/request" + "netidhub-saas-be/app/module/menu_modules/service" + "netidhub-saas-be/utils/paginator" + "strconv" + + "github.com/gofiber/fiber/v2" + + utilRes "netidhub-saas-be/utils/response" + utilVal "netidhub-saas-be/utils/validator" +) + +type menuModulesController struct { + menuModulesService service.MenuModulesService +} + +type MenuModulesController interface { + All(c *fiber.Ctx) error + GetByMenuId(c *fiber.Ctx) error + GetByModuleId(c *fiber.Ctx) error + Show(c *fiber.Ctx) error + Save(c *fiber.Ctx) error + SaveBatch(c *fiber.Ctx) error + Update(c *fiber.Ctx) error + Delete(c *fiber.Ctx) error +} + +func NewMenuModulesController(menuModulesService service.MenuModulesService) MenuModulesController { + return &menuModulesController{ + menuModulesService: menuModulesService, + } +} + +// All MenuModules +// @Summary Get all MenuModules +// @Description API for getting all MenuModules +// @Tags MenuModules +// @Security Bearer +// @Param menu_id query int false "Menu ID" +// @Param module_id query int false "Module ID" +// @Param req query paginator.Pagination false "pagination parameters" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /menu-modules [get] +func (_i *menuModulesController) All(c *fiber.Ctx) error { + paginate, err := paginator.Paginate(c) + if err != nil { + return err + } + + req := request.MenuModulesQueryRequest{ + Pagination: paginate, + } + + if menuId := c.Query("menu_id"); menuId != "" { + id, _ := strconv.ParseUint(menuId, 10, 0) + menuIdUint := uint(id) + req.MenuId = &menuIdUint + } + + if moduleId := c.Query("module_id"); moduleId != "" { + id, _ := strconv.ParseUint(moduleId, 10, 0) + moduleIdUint := uint(id) + req.ModuleId = &moduleIdUint + } + + menuModulesData, paging, err := _i.menuModulesService.All(req) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"MenuModules list successfully retrieved"}, + Data: menuModulesData, + Meta: paging, + }) +} + +// GetByMenuId get MenuModules by Menu ID +// @Summary Get MenuModules by Menu ID +// @Description API for getting MenuModules by Menu ID +// @Tags MenuModules +// @Security Bearer +// @Param menu_id path int true "Menu ID" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /menu-modules/menu/{menu_id} [get] +func (_i *menuModulesController) GetByMenuId(c *fiber.Ctx) error { + menuId, err := strconv.ParseUint(c.Params("menu_id"), 10, 0) + if err != nil { + return err + } + + menuModulesData, err := _i.menuModulesService.GetByMenuId(uint(menuId)) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"MenuModules by menu successfully retrieved"}, + Data: menuModulesData, + }) +} + +// GetByModuleId get MenuModules by Module ID +// @Summary Get MenuModules by Module ID +// @Description API for getting MenuModules by Module ID +// @Tags MenuModules +// @Security Bearer +// @Param module_id path int true "Module ID" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /menu-modules/module/{module_id} [get] +func (_i *menuModulesController) GetByModuleId(c *fiber.Ctx) error { + moduleId, err := strconv.ParseUint(c.Params("module_id"), 10, 0) + if err != nil { + return err + } + + menuModulesData, err := _i.menuModulesService.GetByModuleId(uint(moduleId)) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"MenuModules by module successfully retrieved"}, + Data: menuModulesData, + }) +} + +// Show get one MenuModule +// @Summary Get one MenuModule +// @Description API for getting one MenuModule +// @Tags MenuModules +// @Security Bearer +// @Param id path int true "MenuModule ID" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /menu-modules/{id} [get] +func (_i *menuModulesController) Show(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 0) + if err != nil { + return err + } + + menuModuleData, err := _i.menuModulesService.Show(uint(id)) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"MenuModule successfully retrieved"}, + Data: menuModuleData, + }) +} + +// Save create MenuModule +// @Summary Create MenuModule +// @Description API for create MenuModule +// @Tags MenuModules +// @Security Bearer +// @Param X-Csrf-Token header string false "Insert the X-Csrf-Token" +// @Param payload body request.MenuModulesCreateRequest true "Required payload" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /menu-modules [post] +func (_i *menuModulesController) Save(c *fiber.Ctx) error { + req := new(request.MenuModulesCreateRequest) + if err := utilVal.ParseAndValidate(c, req); err != nil { + return err + } + + err := _i.menuModulesService.Save(*req) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"MenuModule successfully created"}, + }) +} + +// SaveBatch create multiple MenuModules at once +// @Summary Create multiple MenuModules +// @Description API for creating multiple MenuModules at once for a menu +// @Tags MenuModules +// @Security Bearer +// @Param X-Csrf-Token header string false "Insert the X-Csrf-Token" +// @Param payload body request.MenuModulesBatchCreateRequest true "Required payload" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /menu-modules/batch [post] +func (_i *menuModulesController) SaveBatch(c *fiber.Ctx) error { + req := new(request.MenuModulesBatchCreateRequest) + if err := utilVal.ParseAndValidate(c, req); err != nil { + return err + } + + err := _i.menuModulesService.SaveBatch(*req) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"MenuModules successfully created in batch"}, + }) +} + +// Update MenuModule +// @Summary Update MenuModule +// @Description API for update MenuModule +// @Tags MenuModules +// @Security Bearer +// @Param X-Csrf-Token header string false "Insert the X-Csrf-Token" +// @Param payload body request.MenuModulesUpdateRequest true "Required payload" +// @Param id path int true "MenuModule ID" +// @Success 200 {object} response.Response +// @Failure 401 {object} response.Response +// @Failure 404 {object} response.Response +// @Failure 422 {object} response.Response +// @Failure 500 {object} response.Response +// @Router /menu-modules/{id} [put] +func (_i *menuModulesController) Update(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 0) + if err != nil { + return err + } + + req := new(request.MenuModulesUpdateRequest) + if err := utilVal.ParseAndValidate(c, req); err != nil { + return err + } + + err = _i.menuModulesService.Update(uint(id), *req) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"MenuModule successfully updated"}, + }) +} + +// Delete MenuModule +// @Summary Delete MenuModule +// @Description API for delete MenuModule (soft delete) +// @Tags MenuModules +// @Security Bearer +// @Param X-Csrf-Token header string false "Insert the X-Csrf-Token" +// @Param id path int true "MenuModule ID" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /menu-modules/{id} [delete] +func (_i *menuModulesController) Delete(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 0) + if err != nil { + return err + } + + err = _i.menuModulesService.Delete(uint(id)) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"MenuModule successfully deleted"}, + }) +} + diff --git a/app/module/menu_modules/mapper/menu_modules.mapper.go b/app/module/menu_modules/mapper/menu_modules.mapper.go new file mode 100644 index 0000000..92f9d51 --- /dev/null +++ b/app/module/menu_modules/mapper/menu_modules.mapper.go @@ -0,0 +1,42 @@ +package mapper + +import ( + "netidhub-saas-be/app/database/entity" + "netidhub-saas-be/app/module/menu_modules/response" +) + +func MenuModulesResponseMapper(e *entity.MenuModules) *response.MenuModulesResponse { + resp := &response.MenuModulesResponse{ + ID: e.ID, + MenuId: e.MenuId, + ModuleId: e.ModuleId, + Position: e.Position, + ClientId: e.ClientId, + IsActive: e.IsActive, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } + + if e.Menu != nil { + resp.Menu = &response.MenuBasicResponse{ + ID: e.Menu.ID, + Name: e.Menu.Name, + Description: e.Menu.Description, + Icon: e.Menu.Icon, + Group: e.Menu.Group, + } + } + + if e.Module != nil { + resp.Module = &response.ModuleBasicResponse{ + ID: e.Module.ID, + Name: e.Module.Name, + Description: e.Module.Description, + PathUrl: e.Module.PathUrl, + ActionType: e.Module.ActionType, + } + } + + return resp +} + diff --git a/app/module/menu_modules/menu_modules.module.go b/app/module/menu_modules/menu_modules.module.go new file mode 100644 index 0000000..4b34df5 --- /dev/null +++ b/app/module/menu_modules/menu_modules.module.go @@ -0,0 +1,57 @@ +package menu_modules + +import ( + "github.com/gofiber/fiber/v2" + "go.uber.org/fx" + "netidhub-saas-be/app/module/menu_modules/controller" + "netidhub-saas-be/app/module/menu_modules/repository" + "netidhub-saas-be/app/module/menu_modules/service" +) + +// struct of MenuModulesRouter +type MenuModulesRouter struct { + App fiber.Router + Controller *controller.Controller +} + +// register bulky of MenuModules module +var NewMenuModulesModule = fx.Options( + // register repository of MenuModules module + fx.Provide(repository.NewMenuModulesRepository), + + // register service of MenuModules module + fx.Provide(service.NewMenuModulesService), + + // register controller of MenuModules module + fx.Provide(controller.NewController), + + // register router of MenuModules module + fx.Provide(NewMenuModulesRouter), +) + +// init MenuModulesRouter +func NewMenuModulesRouter(fiber *fiber.App, controller *controller.Controller) *MenuModulesRouter { + return &MenuModulesRouter{ + App: fiber, + Controller: controller, + } +} + +// register routes of MenuModules module +func (_i *MenuModulesRouter) RegisterMenuModulesRoutes() { + // define controllers + menuModulesController := _i.Controller.MenuModules + + // define routes + _i.App.Route("/menu-modules", func(router fiber.Router) { + router.Get("/", menuModulesController.All) + router.Get("/:id", menuModulesController.Show) + router.Get("/menu/:menu_id", menuModulesController.GetByMenuId) + router.Get("/module/:module_id", menuModulesController.GetByModuleId) + router.Post("/", menuModulesController.Save) + router.Post("/batch", menuModulesController.SaveBatch) + router.Put("/:id", menuModulesController.Update) + router.Delete("/:id", menuModulesController.Delete) + }) +} + diff --git a/app/module/menu_modules/repository/menu_modules.repository.go b/app/module/menu_modules/repository/menu_modules.repository.go new file mode 100644 index 0000000..bfec3a8 --- /dev/null +++ b/app/module/menu_modules/repository/menu_modules.repository.go @@ -0,0 +1,130 @@ +package repository + +import ( + "fmt" + "netidhub-saas-be/app/database" + "netidhub-saas-be/app/database/entity" + "netidhub-saas-be/app/module/menu_modules/request" + "netidhub-saas-be/utils/paginator" +) + +type menuModulesRepository struct { + DB *database.Database +} + +// MenuModulesRepository define interface of IMenuModulesRepository +type MenuModulesRepository interface { + GetAll(req request.MenuModulesQueryRequest) (menuModules []*entity.MenuModules, paging paginator.Pagination, err error) + GetByMenuId(menuId uint) (menuModules []*entity.MenuModules, err error) + GetByModuleId(moduleId uint) (menuModules []*entity.MenuModules, err error) + FindOne(id uint) (menuModule *entity.MenuModules, err error) + Create(menuModule *entity.MenuModules) (err error) + CreateBatch(menuModules []*entity.MenuModules) (err error) + Update(id uint, menuModule *entity.MenuModules) (err error) + Delete(id uint) (err error) + DeleteByMenuId(menuId uint) (err error) + DeleteByModuleId(moduleId uint) (err error) +} + +func NewMenuModulesRepository(db *database.Database) MenuModulesRepository { + return &menuModulesRepository{ + DB: db, + } +} + +// implement interface of IMenuModulesRepository +func (_i *menuModulesRepository) GetAll(req request.MenuModulesQueryRequest) (menuModules []*entity.MenuModules, paging paginator.Pagination, err error) { + var count int64 + + query := _i.DB.DB.Model(&entity.MenuModules{}) + query = query.Where("is_active = ?", true) + + if req.MenuId != nil { + query = query.Where("menu_id = ?", req.MenuId) + } + if req.ModuleId != nil { + query = query.Where("module_id = ?", req.ModuleId) + } + if req.ClientId != nil { + query = query.Where("client_id = ?", req.ClientId) + } + + // Preload relations + query = query.Preload("Menu").Preload("Module") + + query.Count(&count) + + if req.Pagination.SortBy != "" { + direction := "ASC" + if req.Pagination.Sort == "desc" { + direction = "DESC" + } + query.Order(fmt.Sprintf("%s %s", req.Pagination.SortBy, direction)) + } else { + query.Order("position ASC") + } + + req.Pagination.Count = count + req.Pagination = paginator.Paging(req.Pagination) + + err = query.Offset(req.Pagination.Offset).Limit(req.Pagination.Limit).Find(&menuModules).Error + if err != nil { + return + } + + paging = *req.Pagination + + return +} + +func (_i *menuModulesRepository) GetByMenuId(menuId uint) (menuModules []*entity.MenuModules, err error) { + query := _i.DB.DB.Model(&entity.MenuModules{}) + query = query.Where("menu_id = ? AND is_active = ?", menuId, true) + query = query.Preload("Module") + query = query.Order("position ASC") + err = query.Find(&menuModules).Error + return +} + +func (_i *menuModulesRepository) GetByModuleId(moduleId uint) (menuModules []*entity.MenuModules, err error) { + query := _i.DB.DB.Model(&entity.MenuModules{}) + query = query.Where("module_id = ? AND is_active = ?", moduleId, true) + query = query.Preload("Menu") + err = query.Find(&menuModules).Error + return +} + +func (_i *menuModulesRepository) FindOne(id uint) (menuModule *entity.MenuModules, err error) { + query := _i.DB.DB.Preload("Menu").Preload("Module") + if err := query.First(&menuModule, id).Error; err != nil { + return nil, err + } + return menuModule, nil +} + +func (_i *menuModulesRepository) Create(menuModule *entity.MenuModules) (err error) { + return _i.DB.DB.Create(menuModule).Error +} + +func (_i *menuModulesRepository) CreateBatch(menuModules []*entity.MenuModules) (err error) { + return _i.DB.DB.Create(&menuModules).Error +} + +func (_i *menuModulesRepository) Update(id uint, menuModule *entity.MenuModules) (err error) { + return _i.DB.DB.Model(&entity.MenuModules{}). + Where(&entity.MenuModules{ID: id}). + Updates(menuModule).Error +} + +func (_i *menuModulesRepository) Delete(id uint) error { + return _i.DB.DB.Delete(&entity.MenuModules{}, id).Error +} + +func (_i *menuModulesRepository) DeleteByMenuId(menuId uint) (err error) { + return _i.DB.DB.Where("menu_id = ?", menuId).Delete(&entity.MenuModules{}).Error +} + +func (_i *menuModulesRepository) DeleteByModuleId(moduleId uint) (err error) { + return _i.DB.DB.Where("module_id = ?", moduleId).Delete(&entity.MenuModules{}).Error +} + diff --git a/app/module/menu_modules/request/menu_modules.request.go b/app/module/menu_modules/request/menu_modules.request.go new file mode 100644 index 0000000..74988b6 --- /dev/null +++ b/app/module/menu_modules/request/menu_modules.request.go @@ -0,0 +1,36 @@ +package request + +import ( + "github.com/google/uuid" + "netidhub-saas-be/utils/paginator" +) + +type MenuModulesQueryRequest struct { + MenuId *uint `query:"menu_id"` + ModuleId *uint `query:"module_id"` + ClientId *uuid.UUID `query:"client_id"` + Pagination *paginator.Query `query:"pagination"` +} + +type MenuModulesCreateRequest struct { + MenuId uint `json:"menu_id" validate:"required"` + ModuleId uint `json:"module_id" validate:"required"` + Position *int `json:"position"` + ClientId *uuid.UUID `json:"client_id"` + IsActive *bool `json:"is_active"` +} + +type MenuModulesBatchCreateRequest struct { + MenuId uint `json:"menu_id" validate:"required"` + ModuleIds []uint `json:"module_ids" validate:"required,min=1"` + ClientId *uuid.UUID `json:"client_id"` +} + +type MenuModulesUpdateRequest struct { + MenuId *uint `json:"menu_id"` + ModuleId *uint `json:"module_id"` + Position *int `json:"position"` + ClientId *uuid.UUID `json:"client_id"` + IsActive *bool `json:"is_active"` +} + diff --git a/app/module/menu_modules/response/menu_modules.response.go b/app/module/menu_modules/response/menu_modules.response.go new file mode 100644 index 0000000..5603ad2 --- /dev/null +++ b/app/module/menu_modules/response/menu_modules.response.go @@ -0,0 +1,36 @@ +package response + +import ( + "github.com/google/uuid" + "time" +) + +type MenuModulesResponse struct { + ID uint `json:"id"` + MenuId uint `json:"menu_id"` + ModuleId uint `json:"module_id"` + Position *int `json:"position"` + ClientId *uuid.UUID `json:"client_id"` + IsActive *bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Menu *MenuBasicResponse `json:"menu,omitempty"` + Module *ModuleBasicResponse `json:"module,omitempty"` +} + +type MenuBasicResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Icon *string `json:"icon"` + Group string `json:"group"` +} + +type ModuleBasicResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + PathUrl string `json:"path_url"` + ActionType *string `json:"action_type"` +} + diff --git a/app/module/menu_modules/service/menu_modules.service.go b/app/module/menu_modules/service/menu_modules.service.go new file mode 100644 index 0000000..77fb540 --- /dev/null +++ b/app/module/menu_modules/service/menu_modules.service.go @@ -0,0 +1,161 @@ +package service + +import ( + "github.com/rs/zerolog" + "netidhub-saas-be/app/database/entity" + "netidhub-saas-be/app/module/menu_modules/mapper" + "netidhub-saas-be/app/module/menu_modules/repository" + "netidhub-saas-be/app/module/menu_modules/request" + "netidhub-saas-be/app/module/menu_modules/response" + "netidhub-saas-be/utils/paginator" +) + +// MenuModulesService +type menuModulesService struct { + Repo repository.MenuModulesRepository + Log zerolog.Logger +} + +// MenuModulesService define interface of IMenuModulesService +type MenuModulesService interface { + All(req request.MenuModulesQueryRequest) (menuModules []*response.MenuModulesResponse, paging paginator.Pagination, err error) + GetByMenuId(menuId uint) (menuModules []*response.MenuModulesResponse, err error) + GetByModuleId(moduleId uint) (menuModules []*response.MenuModulesResponse, err error) + Show(id uint) (menuModule *response.MenuModulesResponse, err error) + Save(req request.MenuModulesCreateRequest) (err error) + SaveBatch(req request.MenuModulesBatchCreateRequest) (err error) + Update(id uint, req request.MenuModulesUpdateRequest) (err error) + Delete(id uint) error +} + +// NewMenuModulesService init MenuModulesService +func NewMenuModulesService(repo repository.MenuModulesRepository, log zerolog.Logger) MenuModulesService { + return &menuModulesService{ + Repo: repo, + Log: log, + } +} + +// All implement interface of MenuModulesService +func (_i *menuModulesService) All(req request.MenuModulesQueryRequest) (menuModules []*response.MenuModulesResponse, paging paginator.Pagination, err error) { + results, paging, err := _i.Repo.GetAll(req) + if err != nil { + return + } + + for _, result := range results { + menuModules = append(menuModules, mapper.MenuModulesResponseMapper(result)) + } + + return +} + +func (_i *menuModulesService) GetByMenuId(menuId uint) (menuModules []*response.MenuModulesResponse, err error) { + results, err := _i.Repo.GetByMenuId(menuId) + if err != nil { + return nil, err + } + + for _, result := range results { + menuModules = append(menuModules, mapper.MenuModulesResponseMapper(result)) + } + + return +} + +func (_i *menuModulesService) GetByModuleId(moduleId uint) (menuModules []*response.MenuModulesResponse, err error) { + results, err := _i.Repo.GetByModuleId(moduleId) + if err != nil { + return nil, err + } + + for _, result := range results { + menuModules = append(menuModules, mapper.MenuModulesResponseMapper(result)) + } + + return +} + +func (_i *menuModulesService) Show(id uint) (menuModule *response.MenuModulesResponse, err error) { + result, err := _i.Repo.FindOne(id) + if err != nil { + return nil, err + } + + return mapper.MenuModulesResponseMapper(result), nil +} + +func (_i *menuModulesService) Save(req request.MenuModulesCreateRequest) (err error) { + _i.Log.Info().Interface("data", req).Msg("Creating menu module") + + isActive := true + if req.IsActive != nil { + isActive = *req.IsActive + } + + menuModule := &entity.MenuModules{ + MenuId: req.MenuId, + ModuleId: req.ModuleId, + Position: req.Position, + ClientId: req.ClientId, + IsActive: &isActive, + } + + return _i.Repo.Create(menuModule) +} + +func (_i *menuModulesService) SaveBatch(req request.MenuModulesBatchCreateRequest) (err error) { + _i.Log.Info().Interface("data", req).Msg("Creating menu modules batch") + + isActive := true + var menuModules []*entity.MenuModules + + for idx, moduleId := range req.ModuleIds { + position := idx + 1 + menuModule := &entity.MenuModules{ + MenuId: req.MenuId, + ModuleId: moduleId, + Position: &position, + ClientId: req.ClientId, + IsActive: &isActive, + } + menuModules = append(menuModules, menuModule) + } + + return _i.Repo.CreateBatch(menuModules) +} + +func (_i *menuModulesService) Update(id uint, req request.MenuModulesUpdateRequest) (err error) { + _i.Log.Info().Interface("data", req).Msg("Updating menu module") + + menuModule := &entity.MenuModules{} + if req.MenuId != nil { + menuModule.MenuId = *req.MenuId + } + if req.ModuleId != nil { + menuModule.ModuleId = *req.ModuleId + } + if req.Position != nil { + menuModule.Position = req.Position + } + if req.ClientId != nil { + menuModule.ClientId = req.ClientId + } + if req.IsActive != nil { + menuModule.IsActive = req.IsActive + } + + return _i.Repo.Update(id, menuModule) +} + +func (_i *menuModulesService) Delete(id uint) error { + result, err := _i.Repo.FindOne(id) + if err != nil { + return err + } + + isActive := false + result.IsActive = &isActive + return _i.Repo.Update(id, result) +} + diff --git a/app/module/user_level_module_accesses/controller/controller.go b/app/module/user_level_module_accesses/controller/controller.go new file mode 100644 index 0000000..a0b0acb --- /dev/null +++ b/app/module/user_level_module_accesses/controller/controller.go @@ -0,0 +1,14 @@ +package controller + +import "netidhub-saas-be/app/module/user_level_module_accesses/service" + +type Controller struct { + UserLevelModuleAccesses UserLevelModuleAccessesController +} + +func NewController(userLevelModuleAccessesService service.UserLevelModuleAccessesService) *Controller { + return &Controller{ + UserLevelModuleAccesses: NewUserLevelModuleAccessesController(userLevelModuleAccessesService), + } +} + diff --git a/app/module/user_level_module_accesses/controller/user_level_module_accesses.controller.go b/app/module/user_level_module_accesses/controller/user_level_module_accesses.controller.go new file mode 100644 index 0000000..9e5b924 --- /dev/null +++ b/app/module/user_level_module_accesses/controller/user_level_module_accesses.controller.go @@ -0,0 +1,330 @@ +package controller + +import ( + "netidhub-saas-be/app/module/user_level_module_accesses/request" + "netidhub-saas-be/app/module/user_level_module_accesses/response" + "netidhub-saas-be/app/module/user_level_module_accesses/service" + "netidhub-saas-be/utils/paginator" + "strconv" + + "github.com/gofiber/fiber/v2" + + utilRes "netidhub-saas-be/utils/response" + utilVal "netidhub-saas-be/utils/validator" +) + +type userLevelModuleAccessesController struct { + userLevelModuleAccessesService service.UserLevelModuleAccessesService +} + +type UserLevelModuleAccessesController interface { + All(c *fiber.Ctx) error + GetByUserLevelId(c *fiber.Ctx) error + GetByModuleId(c *fiber.Ctx) error + CheckAccess(c *fiber.Ctx) error + Show(c *fiber.Ctx) error + Save(c *fiber.Ctx) error + SaveBatch(c *fiber.Ctx) error + Update(c *fiber.Ctx) error + Delete(c *fiber.Ctx) error +} + +func NewUserLevelModuleAccessesController(userLevelModuleAccessesService service.UserLevelModuleAccessesService) UserLevelModuleAccessesController { + return &userLevelModuleAccessesController{ + userLevelModuleAccessesService: userLevelModuleAccessesService, + } +} + +// All UserLevelModuleAccesses +// @Summary Get all UserLevelModuleAccesses +// @Description API for getting all UserLevelModuleAccesses +// @Tags UserLevelModuleAccesses +// @Security Bearer +// @Param user_level_id query int false "User Level ID" +// @Param module_id query int false "Module ID" +// @Param can_access query bool false "Can Access" +// @Param req query paginator.Pagination false "pagination parameters" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /user-level-module-accesses [get] +func (_i *userLevelModuleAccessesController) All(c *fiber.Ctx) error { + paginate, err := paginator.Paginate(c) + if err != nil { + return err + } + + req := request.UserLevelModuleAccessesQueryRequest{ + Pagination: paginate, + } + + if userLevelId := c.Query("user_level_id"); userLevelId != "" { + id, _ := strconv.ParseUint(userLevelId, 10, 0) + userLevelIdUint := uint(id) + req.UserLevelId = &userLevelIdUint + } + + if moduleId := c.Query("module_id"); moduleId != "" { + id, _ := strconv.ParseUint(moduleId, 10, 0) + moduleIdUint := uint(id) + req.ModuleId = &moduleIdUint + } + + if canAccess := c.Query("can_access"); canAccess != "" { + canAccessBool := canAccess == "true" + req.CanAccess = &canAccessBool + } + + accessesData, paging, err := _i.userLevelModuleAccessesService.All(req) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"UserLevelModuleAccesses list successfully retrieved"}, + Data: accessesData, + Meta: paging, + }) +} + +// GetByUserLevelId get UserLevelModuleAccesses by User Level ID +// @Summary Get UserLevelModuleAccesses by User Level ID +// @Description API for getting UserLevelModuleAccesses by User Level ID +// @Tags UserLevelModuleAccesses +// @Security Bearer +// @Param user_level_id path int true "User Level ID" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /user-level-module-accesses/user-level/{user_level_id} [get] +func (_i *userLevelModuleAccessesController) GetByUserLevelId(c *fiber.Ctx) error { + userLevelId, err := strconv.ParseUint(c.Params("user_level_id"), 10, 0) + if err != nil { + return err + } + + accessesData, err := _i.userLevelModuleAccessesService.GetByUserLevelId(uint(userLevelId)) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"UserLevelModuleAccesses by user level successfully retrieved"}, + Data: accessesData, + }) +} + +// GetByModuleId get UserLevelModuleAccesses by Module ID +// @Summary Get UserLevelModuleAccesses by Module ID +// @Description API for getting UserLevelModuleAccesses by Module ID +// @Tags UserLevelModuleAccesses +// @Security Bearer +// @Param module_id path int true "Module ID" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /user-level-module-accesses/module/{module_id} [get] +func (_i *userLevelModuleAccessesController) GetByModuleId(c *fiber.Ctx) error { + moduleId, err := strconv.ParseUint(c.Params("module_id"), 10, 0) + if err != nil { + return err + } + + accessesData, err := _i.userLevelModuleAccessesService.GetByModuleId(uint(moduleId)) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"UserLevelModuleAccesses by module successfully retrieved"}, + Data: accessesData, + }) +} + +// CheckAccess check if user level has access to module +// @Summary Check user level module access +// @Description API for checking if user level has access to module +// @Tags UserLevelModuleAccesses +// @Security Bearer +// @Param payload body request.CheckAccessRequest true "Required payload" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /user-level-module-accesses/check-access [post] +func (_i *userLevelModuleAccessesController) CheckAccess(c *fiber.Ctx) error { + req := new(request.CheckAccessRequest) + if err := utilVal.ParseAndValidate(c, req); err != nil { + return err + } + + hasAccess, err := _i.userLevelModuleAccessesService.CheckAccess(req.UserLevelId, req.ModuleId) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"Access check completed"}, + Data: response.CheckAccessResponse{HasAccess: hasAccess}, + }) +} + +// Show get one UserLevelModuleAccess +// @Summary Get one UserLevelModuleAccess +// @Description API for getting one UserLevelModuleAccess +// @Tags UserLevelModuleAccesses +// @Security Bearer +// @Param id path int true "UserLevelModuleAccess ID" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /user-level-module-accesses/{id} [get] +func (_i *userLevelModuleAccessesController) Show(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 0) + if err != nil { + return err + } + + accessData, err := _i.userLevelModuleAccessesService.Show(uint(id)) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"UserLevelModuleAccess successfully retrieved"}, + Data: accessData, + }) +} + +// Save create UserLevelModuleAccess +// @Summary Create UserLevelModuleAccess +// @Description API for create UserLevelModuleAccess +// @Tags UserLevelModuleAccesses +// @Security Bearer +// @Param X-Csrf-Token header string false "Insert the X-Csrf-Token" +// @Param payload body request.UserLevelModuleAccessesCreateRequest true "Required payload" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /user-level-module-accesses [post] +func (_i *userLevelModuleAccessesController) Save(c *fiber.Ctx) error { + req := new(request.UserLevelModuleAccessesCreateRequest) + if err := utilVal.ParseAndValidate(c, req); err != nil { + return err + } + + err := _i.userLevelModuleAccessesService.Save(*req) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"UserLevelModuleAccess successfully created"}, + }) +} + +// SaveBatch create multiple UserLevelModuleAccesses at once +// @Summary Create multiple UserLevelModuleAccesses +// @Description API for creating multiple UserLevelModuleAccesses at once for a user level +// @Tags UserLevelModuleAccesses +// @Security Bearer +// @Param X-Csrf-Token header string false "Insert the X-Csrf-Token" +// @Param payload body request.UserLevelModuleAccessesBatchCreateRequest true "Required payload" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /user-level-module-accesses/batch [post] +func (_i *userLevelModuleAccessesController) SaveBatch(c *fiber.Ctx) error { + req := new(request.UserLevelModuleAccessesBatchCreateRequest) + if err := utilVal.ParseAndValidate(c, req); err != nil { + return err + } + + err := _i.userLevelModuleAccessesService.SaveBatch(*req) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"UserLevelModuleAccesses successfully created in batch"}, + }) +} + +// Update UserLevelModuleAccess +// @Summary Update UserLevelModuleAccess +// @Description API for update UserLevelModuleAccess +// @Tags UserLevelModuleAccesses +// @Security Bearer +// @Param X-Csrf-Token header string false "Insert the X-Csrf-Token" +// @Param payload body request.UserLevelModuleAccessesUpdateRequest true "Required payload" +// @Param id path int true "UserLevelModuleAccess ID" +// @Success 200 {object} response.Response +// @Failure 401 {object} response.Response +// @Failure 404 {object} response.Response +// @Failure 422 {object} response.Response +// @Failure 500 {object} response.Response +// @Router /user-level-module-accesses/{id} [put] +func (_i *userLevelModuleAccessesController) Update(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 0) + if err != nil { + return err + } + + req := new(request.UserLevelModuleAccessesUpdateRequest) + if err := utilVal.ParseAndValidate(c, req); err != nil { + return err + } + + err = _i.userLevelModuleAccessesService.Update(uint(id), *req) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"UserLevelModuleAccess successfully updated"}, + }) +} + +// Delete UserLevelModuleAccess +// @Summary Delete UserLevelModuleAccess +// @Description API for delete UserLevelModuleAccess (soft delete) +// @Tags UserLevelModuleAccesses +// @Security Bearer +// @Param X-Csrf-Token header string false "Insert the X-Csrf-Token" +// @Param id path int true "UserLevelModuleAccess ID" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /user-level-module-accesses/{id} [delete] +func (_i *userLevelModuleAccessesController) Delete(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 0) + if err != nil { + return err + } + + err = _i.userLevelModuleAccessesService.Delete(uint(id)) + if err != nil { + return err + } + + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"UserLevelModuleAccess successfully deleted"}, + }) +} + diff --git a/app/module/user_level_module_accesses/mapper/user_level_module_accesses.mapper.go b/app/module/user_level_module_accesses/mapper/user_level_module_accesses.mapper.go new file mode 100644 index 0000000..ec1e7f6 --- /dev/null +++ b/app/module/user_level_module_accesses/mapper/user_level_module_accesses.mapper.go @@ -0,0 +1,42 @@ +package mapper + +import ( + "netidhub-saas-be/app/database/entity" + "netidhub-saas-be/app/module/user_level_module_accesses/response" +) + +func UserLevelModuleAccessesResponseMapper(e *entity.UserLevelModuleAccesses) *response.UserLevelModuleAccessesResponse { + resp := &response.UserLevelModuleAccessesResponse{ + ID: e.ID, + UserLevelId: e.UserLevelId, + ModuleId: e.ModuleId, + CanAccess: e.CanAccess, + ClientId: e.ClientId, + IsActive: e.IsActive, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } + + if e.UserLevel != nil { + resp.UserLevel = &response.UserLevelResponse{ + ID: e.UserLevel.ID, + Name: e.UserLevel.Name, + AliasName: e.UserLevel.AliasName, + LevelNumber: e.UserLevel.LevelNumber, + Group: e.UserLevel.Group, + } + } + + if e.Module != nil { + resp.Module = &response.ModuleDetailResponse{ + ID: e.Module.ID, + Name: e.Module.Name, + Description: e.Module.Description, + PathUrl: e.Module.PathUrl, + ActionType: e.Module.ActionType, + } + } + + return resp +} + diff --git a/app/module/user_level_module_accesses/repository/user_level_module_accesses.repository.go b/app/module/user_level_module_accesses/repository/user_level_module_accesses.repository.go new file mode 100644 index 0000000..a29a065 --- /dev/null +++ b/app/module/user_level_module_accesses/repository/user_level_module_accesses.repository.go @@ -0,0 +1,145 @@ +package repository + +import ( + "fmt" + "netidhub-saas-be/app/database" + "netidhub-saas-be/app/database/entity" + "netidhub-saas-be/app/module/user_level_module_accesses/request" + "netidhub-saas-be/utils/paginator" +) + +type userLevelModuleAccessesRepository struct { + DB *database.Database +} + +// UserLevelModuleAccessesRepository define interface of IUserLevelModuleAccessesRepository +type UserLevelModuleAccessesRepository interface { + GetAll(req request.UserLevelModuleAccessesQueryRequest) (accesses []*entity.UserLevelModuleAccesses, paging paginator.Pagination, err error) + GetByUserLevelId(userLevelId uint) (accesses []*entity.UserLevelModuleAccesses, err error) + GetByModuleId(moduleId uint) (accesses []*entity.UserLevelModuleAccesses, err error) + CheckAccess(userLevelId uint, moduleId uint) (hasAccess bool, err error) + FindOne(id uint) (access *entity.UserLevelModuleAccesses, err error) + Create(access *entity.UserLevelModuleAccesses) (err error) + CreateBatch(accesses []*entity.UserLevelModuleAccesses) (err error) + Update(id uint, access *entity.UserLevelModuleAccesses) (err error) + Delete(id uint) (err error) + DeleteByUserLevelId(userLevelId uint) (err error) + DeleteByModuleId(moduleId uint) (err error) +} + +func NewUserLevelModuleAccessesRepository(db *database.Database) UserLevelModuleAccessesRepository { + return &userLevelModuleAccessesRepository{ + DB: db, + } +} + +// implement interface of IUserLevelModuleAccessesRepository +func (_i *userLevelModuleAccessesRepository) GetAll(req request.UserLevelModuleAccessesQueryRequest) (accesses []*entity.UserLevelModuleAccesses, paging paginator.Pagination, err error) { + var count int64 + + query := _i.DB.DB.Model(&entity.UserLevelModuleAccesses{}) + query = query.Where("is_active = ?", true) + + if req.UserLevelId != nil { + query = query.Where("user_level_id = ?", req.UserLevelId) + } + if req.ModuleId != nil { + query = query.Where("module_id = ?", req.ModuleId) + } + if req.ClientId != nil { + query = query.Where("client_id = ?", req.ClientId) + } + if req.CanAccess != nil { + query = query.Where("can_access = ?", req.CanAccess) + } + + // Preload relations + query = query.Preload("UserLevel").Preload("Module") + + query.Count(&count) + + if req.Pagination.SortBy != "" { + direction := "ASC" + if req.Pagination.Sort == "desc" { + direction = "DESC" + } + query.Order(fmt.Sprintf("%s %s", req.Pagination.SortBy, direction)) + } + + req.Pagination.Count = count + req.Pagination = paginator.Paging(req.Pagination) + + err = query.Offset(req.Pagination.Offset).Limit(req.Pagination.Limit).Find(&accesses).Error + if err != nil { + return + } + + paging = *req.Pagination + + return +} + +func (_i *userLevelModuleAccessesRepository) GetByUserLevelId(userLevelId uint) (accesses []*entity.UserLevelModuleAccesses, err error) { + query := _i.DB.DB.Model(&entity.UserLevelModuleAccesses{}) + query = query.Where("user_level_id = ? AND is_active = ?", userLevelId, true) + query = query.Preload("Module") + err = query.Find(&accesses).Error + return +} + +func (_i *userLevelModuleAccessesRepository) GetByModuleId(moduleId uint) (accesses []*entity.UserLevelModuleAccesses, err error) { + query := _i.DB.DB.Model(&entity.UserLevelModuleAccesses{}) + query = query.Where("module_id = ? AND is_active = ?", moduleId, true) + query = query.Preload("UserLevel") + err = query.Find(&accesses).Error + return +} + +func (_i *userLevelModuleAccessesRepository) CheckAccess(userLevelId uint, moduleId uint) (hasAccess bool, err error) { + var access entity.UserLevelModuleAccesses + query := _i.DB.DB.Model(&entity.UserLevelModuleAccesses{}) + query = query.Where("user_level_id = ? AND module_id = ? AND is_active = ?", userLevelId, moduleId, true) + + err = query.First(&access).Error + if err != nil { + // Jika tidak ada record, berarti tidak ada akses + return false, nil + } + + return access.CanAccess, nil +} + +func (_i *userLevelModuleAccessesRepository) FindOne(id uint) (access *entity.UserLevelModuleAccesses, err error) { + query := _i.DB.DB.Preload("UserLevel").Preload("Module") + if err := query.First(&access, id).Error; err != nil { + return nil, err + } + return access, nil +} + +func (_i *userLevelModuleAccessesRepository) Create(access *entity.UserLevelModuleAccesses) (err error) { + return _i.DB.DB.Create(access).Error +} + +func (_i *userLevelModuleAccessesRepository) CreateBatch(accesses []*entity.UserLevelModuleAccesses) (err error) { + return _i.DB.DB.Create(&accesses).Error +} + +func (_i *userLevelModuleAccessesRepository) Update(id uint, access *entity.UserLevelModuleAccesses) (err error) { + return _i.DB.DB.Model(&entity.UserLevelModuleAccesses{}). + Where(&entity.UserLevelModuleAccesses{ID: id}). + Updates(access).Error +} + +func (_i *userLevelModuleAccessesRepository) Delete(id uint) error { + return _i.DB.DB.Delete(&entity.UserLevelModuleAccesses{}, id).Error +} + +func (_i *userLevelModuleAccessesRepository) DeleteByUserLevelId(userLevelId uint) (err error) { + return _i.DB.DB.Where("user_level_id = ?", userLevelId).Delete(&entity.UserLevelModuleAccesses{}).Error +} + +func (_i *userLevelModuleAccessesRepository) DeleteByModuleId(moduleId uint) (err error) { + return _i.DB.DB.Where("module_id = ?", moduleId).Delete(&entity.UserLevelModuleAccesses{}).Error +} + diff --git a/app/module/user_level_module_accesses/request/user_level_module_accesses.request.go b/app/module/user_level_module_accesses/request/user_level_module_accesses.request.go new file mode 100644 index 0000000..d9af3c2 --- /dev/null +++ b/app/module/user_level_module_accesses/request/user_level_module_accesses.request.go @@ -0,0 +1,43 @@ +package request + +import ( + "github.com/google/uuid" + "netidhub-saas-be/utils/paginator" +) + +type UserLevelModuleAccessesQueryRequest struct { + UserLevelId *uint `query:"user_level_id"` + ModuleId *uint `query:"module_id"` + CanAccess *bool `query:"can_access"` + ClientId *uuid.UUID `query:"client_id"` + Pagination *paginator.Query `query:"pagination"` +} + +type UserLevelModuleAccessesCreateRequest struct { + UserLevelId uint `json:"user_level_id" validate:"required"` + ModuleId uint `json:"module_id" validate:"required"` + CanAccess bool `json:"can_access"` + ClientId *uuid.UUID `json:"client_id"` + IsActive *bool `json:"is_active"` +} + +type UserLevelModuleAccessesBatchCreateRequest struct { + UserLevelId uint `json:"user_level_id" validate:"required"` + ModuleIds []uint `json:"module_ids" validate:"required,min=1"` + CanAccess bool `json:"can_access"` + ClientId *uuid.UUID `json:"client_id"` +} + +type UserLevelModuleAccessesUpdateRequest struct { + UserLevelId *uint `json:"user_level_id"` + ModuleId *uint `json:"module_id"` + CanAccess *bool `json:"can_access"` + ClientId *uuid.UUID `json:"client_id"` + IsActive *bool `json:"is_active"` +} + +type CheckAccessRequest struct { + UserLevelId uint `json:"user_level_id" validate:"required"` + ModuleId uint `json:"module_id" validate:"required"` +} + diff --git a/app/module/user_level_module_accesses/response/user_level_module_accesses.response.go b/app/module/user_level_module_accesses/response/user_level_module_accesses.response.go new file mode 100644 index 0000000..7df2a2a --- /dev/null +++ b/app/module/user_level_module_accesses/response/user_level_module_accesses.response.go @@ -0,0 +1,40 @@ +package response + +import ( + "github.com/google/uuid" + "time" +) + +type UserLevelModuleAccessesResponse struct { + ID uint `json:"id"` + UserLevelId uint `json:"user_level_id"` + ModuleId uint `json:"module_id"` + CanAccess bool `json:"can_access"` + ClientId *uuid.UUID `json:"client_id"` + IsActive *bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + UserLevel *UserLevelResponse `json:"user_level,omitempty"` + Module *ModuleDetailResponse `json:"module,omitempty"` +} + +type UserLevelResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + AliasName string `json:"alias_name"` + LevelNumber int `json:"level_number"` + Group *string `json:"group"` +} + +type ModuleDetailResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + PathUrl string `json:"path_url"` + ActionType *string `json:"action_type"` +} + +type CheckAccessResponse struct { + HasAccess bool `json:"has_access"` +} + diff --git a/app/module/user_level_module_accesses/service/user_level_module_accesses.service.go b/app/module/user_level_module_accesses/service/user_level_module_accesses.service.go new file mode 100644 index 0000000..bc0685e --- /dev/null +++ b/app/module/user_level_module_accesses/service/user_level_module_accesses.service.go @@ -0,0 +1,165 @@ +package service + +import ( + "github.com/rs/zerolog" + "netidhub-saas-be/app/database/entity" + "netidhub-saas-be/app/module/user_level_module_accesses/mapper" + "netidhub-saas-be/app/module/user_level_module_accesses/repository" + "netidhub-saas-be/app/module/user_level_module_accesses/request" + "netidhub-saas-be/app/module/user_level_module_accesses/response" + "netidhub-saas-be/utils/paginator" +) + +// UserLevelModuleAccessesService +type userLevelModuleAccessesService struct { + Repo repository.UserLevelModuleAccessesRepository + Log zerolog.Logger +} + +// UserLevelModuleAccessesService define interface of IUserLevelModuleAccessesService +type UserLevelModuleAccessesService interface { + All(req request.UserLevelModuleAccessesQueryRequest) (accesses []*response.UserLevelModuleAccessesResponse, paging paginator.Pagination, err error) + GetByUserLevelId(userLevelId uint) (accesses []*response.UserLevelModuleAccessesResponse, err error) + GetByModuleId(moduleId uint) (accesses []*response.UserLevelModuleAccessesResponse, err error) + CheckAccess(userLevelId uint, moduleId uint) (hasAccess bool, err error) + Show(id uint) (access *response.UserLevelModuleAccessesResponse, err error) + Save(req request.UserLevelModuleAccessesCreateRequest) (err error) + SaveBatch(req request.UserLevelModuleAccessesBatchCreateRequest) (err error) + Update(id uint, req request.UserLevelModuleAccessesUpdateRequest) (err error) + Delete(id uint) error +} + +// NewUserLevelModuleAccessesService init UserLevelModuleAccessesService +func NewUserLevelModuleAccessesService(repo repository.UserLevelModuleAccessesRepository, log zerolog.Logger) UserLevelModuleAccessesService { + return &userLevelModuleAccessesService{ + Repo: repo, + Log: log, + } +} + +// All implement interface of UserLevelModuleAccessesService +func (_i *userLevelModuleAccessesService) All(req request.UserLevelModuleAccessesQueryRequest) (accesses []*response.UserLevelModuleAccessesResponse, paging paginator.Pagination, err error) { + results, paging, err := _i.Repo.GetAll(req) + if err != nil { + return + } + + for _, result := range results { + accesses = append(accesses, mapper.UserLevelModuleAccessesResponseMapper(result)) + } + + return +} + +func (_i *userLevelModuleAccessesService) GetByUserLevelId(userLevelId uint) (accesses []*response.UserLevelModuleAccessesResponse, err error) { + results, err := _i.Repo.GetByUserLevelId(userLevelId) + if err != nil { + return nil, err + } + + for _, result := range results { + accesses = append(accesses, mapper.UserLevelModuleAccessesResponseMapper(result)) + } + + return +} + +func (_i *userLevelModuleAccessesService) GetByModuleId(moduleId uint) (accesses []*response.UserLevelModuleAccessesResponse, err error) { + results, err := _i.Repo.GetByModuleId(moduleId) + if err != nil { + return nil, err + } + + for _, result := range results { + accesses = append(accesses, mapper.UserLevelModuleAccessesResponseMapper(result)) + } + + return +} + +func (_i *userLevelModuleAccessesService) CheckAccess(userLevelId uint, moduleId uint) (hasAccess bool, err error) { + return _i.Repo.CheckAccess(userLevelId, moduleId) +} + +func (_i *userLevelModuleAccessesService) Show(id uint) (access *response.UserLevelModuleAccessesResponse, err error) { + result, err := _i.Repo.FindOne(id) + if err != nil { + return nil, err + } + + return mapper.UserLevelModuleAccessesResponseMapper(result), nil +} + +func (_i *userLevelModuleAccessesService) Save(req request.UserLevelModuleAccessesCreateRequest) (err error) { + _i.Log.Info().Interface("data", req).Msg("Creating user level module access") + + isActive := true + if req.IsActive != nil { + isActive = *req.IsActive + } + + access := &entity.UserLevelModuleAccesses{ + UserLevelId: req.UserLevelId, + ModuleId: req.ModuleId, + CanAccess: req.CanAccess, + ClientId: req.ClientId, + IsActive: &isActive, + } + + return _i.Repo.Create(access) +} + +func (_i *userLevelModuleAccessesService) SaveBatch(req request.UserLevelModuleAccessesBatchCreateRequest) (err error) { + _i.Log.Info().Interface("data", req).Msg("Creating user level module accesses batch") + + isActive := true + var accesses []*entity.UserLevelModuleAccesses + + for _, moduleId := range req.ModuleIds { + access := &entity.UserLevelModuleAccesses{ + UserLevelId: req.UserLevelId, + ModuleId: moduleId, + CanAccess: req.CanAccess, + ClientId: req.ClientId, + IsActive: &isActive, + } + accesses = append(accesses, access) + } + + return _i.Repo.CreateBatch(accesses) +} + +func (_i *userLevelModuleAccessesService) Update(id uint, req request.UserLevelModuleAccessesUpdateRequest) (err error) { + _i.Log.Info().Interface("data", req).Msg("Updating user level module access") + + access := &entity.UserLevelModuleAccesses{} + if req.UserLevelId != nil { + access.UserLevelId = *req.UserLevelId + } + if req.ModuleId != nil { + access.ModuleId = *req.ModuleId + } + if req.CanAccess != nil { + access.CanAccess = *req.CanAccess + } + if req.ClientId != nil { + access.ClientId = req.ClientId + } + if req.IsActive != nil { + access.IsActive = req.IsActive + } + + return _i.Repo.Update(id, access) +} + +func (_i *userLevelModuleAccessesService) Delete(id uint) error { + result, err := _i.Repo.FindOne(id) + if err != nil { + return err + } + + isActive := false + result.IsActive = &isActive + return _i.Repo.Update(id, result) +} + diff --git a/app/module/user_level_module_accesses/user_level_module_accesses.module.go b/app/module/user_level_module_accesses/user_level_module_accesses.module.go new file mode 100644 index 0000000..3d1e5fc --- /dev/null +++ b/app/module/user_level_module_accesses/user_level_module_accesses.module.go @@ -0,0 +1,58 @@ +package user_level_module_accesses + +import ( + "github.com/gofiber/fiber/v2" + "go.uber.org/fx" + "netidhub-saas-be/app/module/user_level_module_accesses/controller" + "netidhub-saas-be/app/module/user_level_module_accesses/repository" + "netidhub-saas-be/app/module/user_level_module_accesses/service" +) + +// struct of UserLevelModuleAccessesRouter +type UserLevelModuleAccessesRouter struct { + App fiber.Router + Controller *controller.Controller +} + +// register bulky of UserLevelModuleAccesses module +var NewUserLevelModuleAccessesModule = fx.Options( + // register repository of UserLevelModuleAccesses module + fx.Provide(repository.NewUserLevelModuleAccessesRepository), + + // register service of UserLevelModuleAccesses module + fx.Provide(service.NewUserLevelModuleAccessesService), + + // register controller of UserLevelModuleAccesses module + fx.Provide(controller.NewController), + + // register router of UserLevelModuleAccesses module + fx.Provide(NewUserLevelModuleAccessesRouter), +) + +// init UserLevelModuleAccessesRouter +func NewUserLevelModuleAccessesRouter(fiber *fiber.App, controller *controller.Controller) *UserLevelModuleAccessesRouter { + return &UserLevelModuleAccessesRouter{ + App: fiber, + Controller: controller, + } +} + +// register routes of UserLevelModuleAccesses module +func (_i *UserLevelModuleAccessesRouter) RegisterUserLevelModuleAccessesRoutes() { + // define controllers + userLevelModuleAccessesController := _i.Controller.UserLevelModuleAccesses + + // define routes + _i.App.Route("/user-level-module-accesses", func(router fiber.Router) { + router.Get("/", userLevelModuleAccessesController.All) + router.Get("/:id", userLevelModuleAccessesController.Show) + router.Get("/user-level/:user_level_id", userLevelModuleAccessesController.GetByUserLevelId) + router.Get("/module/:module_id", userLevelModuleAccessesController.GetByModuleId) + router.Post("/", userLevelModuleAccessesController.Save) + router.Post("/batch", userLevelModuleAccessesController.SaveBatch) + router.Post("/check-access", userLevelModuleAccessesController.CheckAccess) + router.Put("/:id", userLevelModuleAccessesController.Update) + router.Delete("/:id", userLevelModuleAccessesController.Delete) + }) +} + diff --git a/docs/IMPLEMENTATION_SUMMARY_MENU_MODULE_ACCESS.md b/docs/IMPLEMENTATION_SUMMARY_MENU_MODULE_ACCESS.md new file mode 100644 index 0000000..57ec3e1 --- /dev/null +++ b/docs/IMPLEMENTATION_SUMMARY_MENU_MODULE_ACCESS.md @@ -0,0 +1,342 @@ +# ๐Ÿ“ฆ Implementation Summary - Menu Module Access Control System + +## โœ… Yang Sudah Dibuat + +### 1. **Database Entities** (3 files) + +#### โœ“ `app/database/entity/menu_modules.entity.go` +- Relasi many-to-many antara menu dan modul +- Satu menu bisa punya banyak modul (view, create, edit, delete, etc.) + +#### โœ“ `app/database/entity/user_level_module_accesses.entity.go` +- Mengatur akses user-level ke modul-modul tertentu +- Kontrol akses granular per modul + +#### โœ“ `app/database/entity/master_modules.entity.go` (Enhanced) +- Ditambahkan field `action_type` untuk membedakan jenis aksi (view, create, edit, delete, approve, export) + +### 2. **Repositories** (2 files) + +#### โœ“ `app/module/menu_modules/repository/menu_modules.repository.go` +Fungsi yang tersedia: +- `GetAll()` - Get all menu-modules dengan pagination +- `GetByMenuId()` - Get modules berdasarkan menu +- `GetByModuleId()` - Get menus berdasarkan module +- `FindOne()` - Get satu menu-module +- `Create()` - Create satu menu-module +- `CreateBatch()` - Create banyak menu-modules sekaligus +- `Update()` - Update menu-module +- `Delete()` - Delete menu-module +- `DeleteByMenuId()` - Delete semua modules dari menu +- `DeleteByModuleId()` - Delete semua menus dari module + +#### โœ“ `app/module/user_level_module_accesses/repository/user_level_module_accesses.repository.go` +Fungsi yang tersedia: +- `GetAll()` - Get all accesses dengan pagination +- `GetByUserLevelId()` - Get accesses berdasarkan user level +- `GetByModuleId()` - Get accesses berdasarkan module +- `CheckAccess()` - Check apakah user level punya akses ke module +- `FindOne()` - Get satu access +- `Create()` - Create satu access +- `CreateBatch()` - Create banyak accesses sekaligus +- `Update()` - Update access +- `Delete()` - Delete access + +### 3. **Services** (2 files) + +#### โœ“ `app/module/menu_modules/service/menu_modules.service.go` +Business logic untuk mengelola menu-modules + +#### โœ“ `app/module/user_level_module_accesses/service/user_level_module_accesses.service.go` +Business logic untuk mengelola user level accesses + +### 4. **Controllers** (2 files) + +#### โœ“ `app/module/menu_modules/controller/menu_modules.controller.go` +API Endpoints: +- `GET /menu-modules` - List all +- `GET /menu-modules/:id` - Get one +- `GET /menu-modules/menu/:menu_id` - Get by menu +- `GET /menu-modules/module/:module_id` - Get by module +- `POST /menu-modules` - Create one +- `POST /menu-modules/batch` - Create many +- `PUT /menu-modules/:id` - Update +- `DELETE /menu-modules/:id` - Delete + +#### โœ“ `app/module/user_level_module_accesses/controller/user_level_module_accesses.controller.go` +API Endpoints: +- `GET /user-level-module-accesses` - List all +- `GET /user-level-module-accesses/:id` - Get one +- `GET /user-level-module-accesses/user-level/:user_level_id` - Get by user level +- `GET /user-level-module-accesses/module/:module_id` - Get by module +- `POST /user-level-module-accesses` - Create one +- `POST /user-level-module-accesses/batch` - Create many +- `POST /user-level-module-accesses/check-access` - Check access +- `PUT /user-level-module-accesses/:id` - Update +- `DELETE /user-level-module-accesses/:id` - Delete + +### 5. **Middleware** (1 file) + +#### โœ“ `app/middleware/module_access.middleware.go` +3 Middleware functions: +1. **`CheckModuleAccess(moduleId|pathUrl)`** - Check akses berdasarkan module ID atau path URL +2. **`CheckModuleAccessByPath()`** - Auto-detect module dari current path +3. **`CheckMenuAccess(menuId)`** - Check akses ke menu (minimal punya 1 akses modul di menu tersebut) + +### 6. **Request/Response DTOs** (4 files) + +#### โœ“ `app/module/menu_modules/request/menu_modules.request.go` +- MenuModulesQueryRequest +- MenuModulesCreateRequest +- MenuModulesBatchCreateRequest +- MenuModulesUpdateRequest + +#### โœ“ `app/module/menu_modules/response/menu_modules.response.go` +- MenuModulesResponse +- MenuBasicResponse +- ModuleBasicResponse + +#### โœ“ `app/module/user_level_module_accesses/request/user_level_module_accesses.request.go` +- UserLevelModuleAccessesQueryRequest +- UserLevelModuleAccessesCreateRequest +- UserLevelModuleAccessesBatchCreateRequest +- UserLevelModuleAccessesUpdateRequest +- CheckAccessRequest + +#### โœ“ `app/module/user_level_module_accesses/response/user_level_module_accesses.response.go` +- UserLevelModuleAccessesResponse +- UserLevelResponse +- ModuleDetailResponse +- CheckAccessResponse + +### 7. **Mappers** (2 files) + +#### โœ“ `app/module/menu_modules/mapper/menu_modules.mapper.go` +- MenuModulesResponseMapper() + +#### โœ“ `app/module/user_level_module_accesses/mapper/user_level_module_accesses.mapper.go` +- UserLevelModuleAccessesResponseMapper() + +### 8. **Module Registration** (2 files) + +#### โœ“ `app/module/menu_modules/menu_modules.module.go` +- Dependency injection setup +- Route registration + +#### โœ“ `app/module/user_level_module_accesses/user_level_module_accesses.module.go` +- Dependency injection setup +- Route registration + +### 9. **Documentation** (3 files) + +#### โœ“ `docs/MENU_MODULE_ACCESS_SYSTEM.md` +Dokumentasi lengkap tentang: +- Overview sistem +- Database schema +- API endpoints +- Middleware usage +- Implementation examples +- Best practices + +#### โœ“ `docs/MENU_MODULE_QUICK_START.md` +Quick start guide dengan langkah-langkah: +- Setup migration +- Setup data master +- Implementasi di code +- Testing +- Troubleshooting + +#### โœ“ `docs/migrations/004_add_menu_module_access_system.sql` +SQL migration script untuk: +- Alter `master_modules` (add action_type) +- Create `menu_modules` table +- Create `user_level_module_accesses` table +- Indexes dan constraints +- Data migration dari existing structure +- Verification queries + +--- + +## ๐Ÿš€ Langkah Selanjutnya + +### 1. Jalankan Migration + +```bash +psql -U your_username -d your_database -f docs/migrations/004_add_menu_module_access_system.sql +``` + +### 2. Register Modules ke Main App + +Edit `app/router/api.go` atau main file untuk register module: + +```go +import ( + menuModulesModule "netidhub-saas-be/app/module/menu_modules" + userLevelModuleAccessesModule "netidhub-saas-be/app/module/user_level_module_accesses" +) + +// Di dalam fx.Options +fx.Provide( + // ... existing modules ... + menuModulesModule.NewMenuModulesModule, + userLevelModuleAccessesModule.NewUserLevelModuleAccessesModule, +), +``` + +Dan register routes: + +```go +func RegisterRoutes( + menuModulesRouter *menuModulesModule.MenuModulesRouter, + userLevelModuleAccessesRouter *userLevelModuleAccessesModule.UserLevelModuleAccessesRouter, +) { + menuModulesRouter.RegisterMenuModulesRoutes() + userLevelModuleAccessesRouter.RegisterUserLevelModuleAccessesRoutes() +} +``` + +### 3. Setup Data Master + +Jalankan SQL untuk membuat modules, menus, dan accesses. Lihat contoh di `docs/MENU_MODULE_QUICK_START.md`. + +### 4. Terapkan Middleware ke Routes + +Contoh: + +```go +func SetupArticleRoutes(app *fiber.App, db *database.Database) { + moduleAccessMw := middleware.NewModuleAccessMiddleware(db) + + articles := app.Group("/api/articles") + + articles.Get("/", + authMw.ValidateToken(), + moduleAccessMw.CheckModuleAccess(uint(1)), // View module + articleController.GetAll, + ) + + articles.Post("/", + authMw.ValidateToken(), + moduleAccessMw.CheckModuleAccess(uint(2)), // Create module + articleController.Create, + ) +} +``` + +### 5. Testing + +Gunakan contoh testing di `docs/MENU_MODULE_QUICK_START.md` untuk validasi implementasi. + +--- + +## ๐Ÿ“Š Struktur yang Dibuat + +``` +netidhub-saas-be/ +โ”œโ”€โ”€ app/ +โ”‚ โ”œโ”€โ”€ database/ +โ”‚ โ”‚ โ””โ”€โ”€ entity/ +โ”‚ โ”‚ โ”œโ”€โ”€ menu_modules.entity.go โœ“ +โ”‚ โ”‚ โ”œโ”€โ”€ user_level_module_accesses.entity.go โœ“ +โ”‚ โ”‚ โ””โ”€โ”€ master_modules.entity.go (Enhanced) โœ“ +โ”‚ โ”œโ”€โ”€ middleware/ +โ”‚ โ”‚ โ””โ”€โ”€ module_access.middleware.go โœ“ +โ”‚ โ””โ”€โ”€ module/ +โ”‚ โ”œโ”€โ”€ menu_modules/ +โ”‚ โ”‚ โ”œโ”€โ”€ controller/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ controller.go โœ“ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ menu_modules.controller.go โœ“ +โ”‚ โ”‚ โ”œโ”€โ”€ mapper/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ menu_modules.mapper.go โœ“ +โ”‚ โ”‚ โ”œโ”€โ”€ repository/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ menu_modules.repository.go โœ“ +โ”‚ โ”‚ โ”œโ”€โ”€ request/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ menu_modules.request.go โœ“ +โ”‚ โ”‚ โ”œโ”€โ”€ response/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ menu_modules.response.go โœ“ +โ”‚ โ”‚ โ”œโ”€โ”€ service/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ menu_modules.service.go โœ“ +โ”‚ โ”‚ โ””โ”€โ”€ menu_modules.module.go โœ“ +โ”‚ โ””โ”€โ”€ user_level_module_accesses/ +โ”‚ โ”œโ”€โ”€ controller/ +โ”‚ โ”‚ โ”œโ”€โ”€ controller.go โœ“ +โ”‚ โ”‚ โ””โ”€โ”€ user_level_module_accesses.controller.go โœ“ +โ”‚ โ”œโ”€โ”€ mapper/ +โ”‚ โ”‚ โ””โ”€โ”€ user_level_module_accesses.mapper.go โœ“ +โ”‚ โ”œโ”€โ”€ repository/ +โ”‚ โ”‚ โ””โ”€โ”€ user_level_module_accesses.repository.go โœ“ +โ”‚ โ”œโ”€โ”€ request/ +โ”‚ โ”‚ โ””โ”€โ”€ user_level_module_accesses.request.go โœ“ +โ”‚ โ”œโ”€โ”€ response/ +โ”‚ โ”‚ โ””โ”€โ”€ user_level_module_accesses.response.go โœ“ +โ”‚ โ”œโ”€โ”€ service/ +โ”‚ โ”‚ โ””โ”€โ”€ user_level_module_accesses.service.go โœ“ +โ”‚ โ””โ”€โ”€ user_level_module_accesses.module.go โœ“ +โ””โ”€โ”€ docs/ + โ”œโ”€โ”€ MENU_MODULE_ACCESS_SYSTEM.md โœ“ + โ”œโ”€โ”€ MENU_MODULE_QUICK_START.md โœ“ + โ””โ”€โ”€ migrations/ + โ””โ”€โ”€ 004_add_menu_module_access_system.sql โœ“ + +Total: 27 files created/updated +``` + +--- + +## ๐ŸŽฏ Fitur yang Dapat Dilakukan + +### โœ… Management Menu-Module +- [x] Menghubungkan banyak modul ke satu menu +- [x] Batch create untuk efisiensi +- [x] Get modules by menu +- [x] Get menus by module +- [x] Manage position/urutan modul + +### โœ… Management User Level Access +- [x] Set akses user level ke modul tertentu +- [x] Batch create untuk setup awal +- [x] Check access programmatically +- [x] Get accesses by user level +- [x] Get accesses by module + +### โœ… Middleware Protection +- [x] Check akses berdasarkan module ID +- [x] Check akses berdasarkan path URL +- [x] Auto-detect module dari current path +- [x] Check akses menu +- [x] Set context untuk handler + +### โœ… API Endpoints +- [x] CRUD menu modules +- [x] CRUD user level accesses +- [x] Check access endpoint +- [x] Batch operations +- [x] Filter by various parameters + +--- + +## ๐Ÿ“š Dokumentasi + +Silakan baca dokumentasi lengkap: +1. **[MENU_MODULE_ACCESS_SYSTEM.md](./MENU_MODULE_ACCESS_SYSTEM.md)** - Dokumentasi lengkap +2. **[MENU_MODULE_QUICK_START.md](./MENU_MODULE_QUICK_START.md)** - Quick start guide + +--- + +## ๐Ÿ”ง Support & Troubleshooting + +Jika ada masalah, cek: +1. Migration sudah dijalankan dengan benar +2. Tables `menu_modules` dan `user_level_module_accesses` sudah ada +3. Data master sudah disetup +4. Middleware sudah diterapkan dengan benar +5. User sudah terautentikasi sebelum check access + +Untuk troubleshooting detail, lihat bagian Troubleshooting di `MENU_MODULE_QUICK_START.md`. + +--- + +**Status: โœ… READY TO USE** + +Sistem sudah lengkap dan siap digunakan. Tinggal jalankan migration dan setup data master! + diff --git a/docs/MENU_MODULE_ACCESS_SYSTEM.md b/docs/MENU_MODULE_ACCESS_SYSTEM.md new file mode 100644 index 0000000..06f0f3a --- /dev/null +++ b/docs/MENU_MODULE_ACCESS_SYSTEM.md @@ -0,0 +1,574 @@ +# Menu Module Access Control System + +## ๐Ÿ“‹ Overview + +Sistem ini memaksimalkan penggunaan `master_menus` dan `master_modules` untuk mengatur akses berbasis user-level yang lebih granular. Sistem ini memungkinkan: + +1. **Menu memiliki banyak modul** - Satu menu dapat terdiri dari berbagai modul (view, create, edit, delete, etc.) +2. **Akses berbasis user-level** - User-level dapat dikonfigurasi untuk mengakses modul-modul tertentu +3. **Pengecekan akses otomatis** - Middleware untuk validasi akses sebelum user mengakses endpoint + +## ๐Ÿ—‚๏ธ Database Schema + +### 1. `master_modules` (Enhanced) +Tabel modul yang sudah ditingkatkan dengan field `action_type`. + +```sql +ALTER TABLE master_modules +ADD COLUMN action_type VARCHAR NULL COMMENT 'Tipe aksi: view, create, edit, delete, approve, export, etc'; +``` + +### 2. `menu_modules` (New) +Tabel relasi many-to-many antara menu dan modul. + +```sql +CREATE TABLE menu_modules ( + id SERIAL PRIMARY KEY, + menu_id INT NOT NULL, + module_id INT NOT NULL, + position INT NULL, + client_id UUID NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + FOREIGN KEY (menu_id) REFERENCES master_menus(id), + FOREIGN KEY (module_id) REFERENCES master_modules(id), + UNIQUE(menu_id, module_id) +); + +CREATE INDEX idx_menu_modules_menu_id ON menu_modules(menu_id); +CREATE INDEX idx_menu_modules_module_id ON menu_modules(module_id); +``` + +### 3. `user_level_module_accesses` (New) +Tabel untuk mengatur akses user-level ke modul-modul tertentu. + +```sql +CREATE TABLE user_level_module_accesses ( + id SERIAL PRIMARY KEY, + user_level_id INT NOT NULL, + module_id INT NOT NULL, + can_access BOOLEAN DEFAULT TRUE, + client_id UUID NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + FOREIGN KEY (user_level_id) REFERENCES user_levels(id), + FOREIGN KEY (module_id) REFERENCES master_modules(id), + UNIQUE(user_level_id, module_id) +); + +CREATE INDEX idx_user_level_module_accesses_user_level_id ON user_level_module_accesses(user_level_id); +CREATE INDEX idx_user_level_module_accesses_module_id ON user_level_module_accesses(module_id); +``` + +## ๐Ÿ“Š Entity Relationships + +``` +master_menus (1) โ”€โ”€โ”€โ”€โ”€< (N) menu_modules (N) >โ”€โ”€โ”€โ”€โ”€ (1) master_modules + โ”‚ + โ”‚ (1) + โ”‚ + โˆจ +user_levels (1) โ”€โ”€โ”€โ”€โ”€< (N) user_level_module_accesses (N) >โ”˜ +``` + +## ๐Ÿ”ง API Endpoints + +### Menu Modules API + +#### 1. Get All Menu Modules +```http +GET /api/menu-modules?menu_id=1&page=1&limit=10 +Authorization: Bearer {token} +``` + +#### 2. Get Modules by Menu ID +```http +GET /api/menu-modules/menu/{menu_id} +Authorization: Bearer {token} +``` + +**Response:** +```json +{ + "success": true, + "messages": ["MenuModules by menu successfully retrieved"], + "data": [ + { + "id": 1, + "menu_id": 1, + "module_id": 5, + "position": 1, + "module": { + "id": 5, + "name": "View Articles", + "description": "View article list", + "path_url": "/api/articles", + "action_type": "view" + } + } + ] +} +``` + +#### 3. Create Menu Module +```http +POST /api/menu-modules +Authorization: Bearer {token} +Content-Type: application/json + +{ + "menu_id": 1, + "module_id": 5, + "position": 1 +} +``` + +#### 4. Create Menu Modules in Batch +```http +POST /api/menu-modules/batch +Authorization: Bearer {token} +Content-Type: application/json + +{ + "menu_id": 1, + "module_ids": [5, 6, 7, 8] +} +``` + +#### 5. Update Menu Module +```http +PUT /api/menu-modules/{id} +Authorization: Bearer {token} +Content-Type: application/json + +{ + "position": 2, + "is_active": true +} +``` + +#### 6. Delete Menu Module +```http +DELETE /api/menu-modules/{id} +Authorization: Bearer {token} +``` + +### User Level Module Accesses API + +#### 1. Get All Accesses +```http +GET /api/user-level-module-accesses?user_level_id=1&page=1&limit=10 +Authorization: Bearer {token} +``` + +#### 2. Get Accesses by User Level ID +```http +GET /api/user-level-module-accesses/user-level/{user_level_id} +Authorization: Bearer {token} +``` + +**Response:** +```json +{ + "success": true, + "messages": ["UserLevelModuleAccesses by user level successfully retrieved"], + "data": [ + { + "id": 1, + "user_level_id": 1, + "module_id": 5, + "can_access": true, + "module": { + "id": 5, + "name": "View Articles", + "description": "View article list", + "path_url": "/api/articles", + "action_type": "view" + } + } + ] +} +``` + +#### 3. Check Access +```http +POST /api/user-level-module-accesses/check-access +Authorization: Bearer {token} +Content-Type: application/json + +{ + "user_level_id": 1, + "module_id": 5 +} +``` + +**Response:** +```json +{ + "success": true, + "messages": ["Access check completed"], + "data": { + "has_access": true + } +} +``` + +#### 4. Create Access +```http +POST /api/user-level-module-accesses +Authorization: Bearer {token} +Content-Type: application/json + +{ + "user_level_id": 1, + "module_id": 5, + "can_access": true +} +``` + +#### 5. Create Accesses in Batch +```http +POST /api/user-level-module-accesses/batch +Authorization: Bearer {token} +Content-Type: application/json + +{ + "user_level_id": 1, + "module_ids": [5, 6, 7, 8], + "can_access": true +} +``` + +## ๐Ÿ›ก๏ธ Middleware Usage + +### 1. Check Module Access by Module ID + +```go +import ( + "netidhub-saas-be/app/middleware" +) + +// Di router setup +moduleAccessMiddleware := middleware.NewModuleAccessMiddleware(db) + +// Protect endpoint dengan module ID +app.Get("/api/articles", + authMiddleware.ValidateToken(), + moduleAccessMiddleware.CheckModuleAccess(uint(5)), // module_id = 5 + articleController.GetAll, +) +``` + +### 2. Check Module Access by Path URL + +```go +// Protect endpoint dengan path_url +app.Get("/api/articles", + authMiddleware.ValidateToken(), + moduleAccessMiddleware.CheckModuleAccess("/api/articles"), // path_url + articleController.GetAll, +) +``` + +### 3. Check Module Access by Current Path (Auto) + +```go +// Auto-detect dari path yang sedang diakses +app.Get("/api/articles", + authMiddleware.ValidateToken(), + moduleAccessMiddleware.CheckModuleAccessByPath(), // auto-detect + articleController.GetAll, +) +``` + +### 4. Check Menu Access + +```go +// Check akses ke menu (minimal punya akses ke 1 modul di menu tersebut) +app.Get("/api/articles/menu", + authMiddleware.ValidateToken(), + moduleAccessMiddleware.CheckMenuAccess(uint(1)), // menu_id = 1 + articleController.GetByMenu, +) +``` + +## ๐Ÿ“ Implementation Example + +### Scenario: Article Management System + +#### Step 1: Create Modules + +```sql +-- Insert modules untuk Article +INSERT INTO master_modules (name, description, path_url, action_type, status_id, is_active) VALUES +('View Articles', 'View article list', '/api/articles', 'view', 1, true), +('Create Article', 'Create new article', '/api/articles/create', 'create', 1, true), +('Edit Article', 'Edit existing article', '/api/articles/edit', 'edit', 1, true), +('Delete Article', 'Delete article', '/api/articles/delete', 'delete', 1, true), +('Approve Article', 'Approve article', '/api/articles/approve', 'approve', 1, true); +``` + +#### Step 2: Create Menu + +```sql +-- Insert menu Article +INSERT INTO master_menus (name, description, module_id, icon, "group", position, status_id, is_active) VALUES +('Article Management', 'Manage articles', 1, 'article-icon', 'Content', 1, 1, true); +``` + +#### Step 3: Link Modules to Menu + +```http +POST /api/menu-modules/batch +{ + "menu_id": 1, + "module_ids": [1, 2, 3, 4, 5] +} +``` + +Or via SQL: +```sql +INSERT INTO menu_modules (menu_id, module_id, position, is_active) VALUES +(1, 1, 1, true), -- View Articles +(1, 2, 2, true), -- Create Article +(1, 3, 3, true), -- Edit Article +(1, 4, 4, true), -- Delete Article +(1, 5, 5, true); -- Approve Article +``` + +#### Step 4: Grant Access to User Levels + +**Admin Pusat (user_level_id = 1) - Full Access:** +```http +POST /api/user-level-module-accesses/batch +{ + "user_level_id": 1, + "module_ids": [1, 2, 3, 4, 5], + "can_access": true +} +``` + +**Editor (user_level_id = 2) - Limited Access:** +```http +POST /api/user-level-module-accesses/batch +{ + "user_level_id": 2, + "module_ids": [1, 2, 3], // Only view, create, edit + "can_access": true +} +``` + +**Viewer (user_level_id = 3) - Read Only:** +```http +POST /api/user-level-module-accesses/batch +{ + "user_level_id": 3, + "module_ids": [1], // Only view + "can_access": true +} +``` + +#### Step 5: Protect Routes + +```go +func SetupArticleRoutes(app *fiber.App, db *database.Database, articleController controller.ArticleController) { + moduleAccessMw := middleware.NewModuleAccessMiddleware(db) + + articles := app.Group("/api/articles") + + // View - Accessible by all levels with access + articles.Get("/", + authMiddleware.ValidateToken(), + moduleAccessMw.CheckModuleAccess(uint(1)), // module_id for "View Articles" + articleController.GetAll, + ) + + // Create - Accessible by Admin & Editor only + articles.Post("/", + authMiddleware.ValidateToken(), + moduleAccessMw.CheckModuleAccess(uint(2)), // module_id for "Create Article" + articleController.Create, + ) + + // Edit - Accessible by Admin & Editor only + articles.Put("/:id", + authMiddleware.ValidateToken(), + moduleAccessMw.CheckModuleAccess(uint(3)), // module_id for "Edit Article" + articleController.Update, + ) + + // Delete - Accessible by Admin only + articles.Delete("/:id", + authMiddleware.ValidateToken(), + moduleAccessMw.CheckModuleAccess(uint(4)), // module_id for "Delete Article" + articleController.Delete, + ) + + // Approve - Accessible by Admin only + articles.Post("/:id/approve", + authMiddleware.ValidateToken(), + moduleAccessMw.CheckModuleAccess(uint(5)), // module_id for "Approve Article" + articleController.Approve, + ) +} +``` + +## ๐Ÿ”„ Migration Script + +Create file: `docs/migrations/004_add_menu_module_access_system.sql` + +```sql +-- Migration: Add Menu Module Access System +-- Description: Enhance master_modules and create menu_modules & user_level_module_accesses tables + +-- Step 1: Enhance master_modules with action_type +ALTER TABLE master_modules +ADD COLUMN IF NOT EXISTS action_type VARCHAR NULL; + +COMMENT ON COLUMN master_modules.action_type IS 'Tipe aksi: view, create, edit, delete, approve, export, etc'; + +-- Step 2: Create menu_modules table +CREATE TABLE IF NOT EXISTS menu_modules ( + id SERIAL PRIMARY KEY, + menu_id INT NOT NULL, + module_id INT NOT NULL, + position INT NULL, + client_id UUID NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + FOREIGN KEY (menu_id) REFERENCES master_menus(id) ON DELETE CASCADE, + FOREIGN KEY (module_id) REFERENCES master_modules(id) ON DELETE CASCADE, + UNIQUE(menu_id, module_id, client_id) +); + +CREATE INDEX idx_menu_modules_menu_id ON menu_modules(menu_id); +CREATE INDEX idx_menu_modules_module_id ON menu_modules(module_id); +CREATE INDEX idx_menu_modules_client_id ON menu_modules(client_id); +CREATE INDEX idx_menu_modules_is_active ON menu_modules(is_active); + +COMMENT ON TABLE menu_modules IS 'Relasi many-to-many antara menu dan modul'; +COMMENT ON COLUMN menu_modules.position IS 'Urutan modul dalam menu'; + +-- Step 3: Create user_level_module_accesses table +CREATE TABLE IF NOT EXISTS user_level_module_accesses ( + id SERIAL PRIMARY KEY, + user_level_id INT NOT NULL, + module_id INT NOT NULL, + can_access BOOLEAN DEFAULT TRUE, + client_id UUID NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + FOREIGN KEY (user_level_id) REFERENCES user_levels(id) ON DELETE CASCADE, + FOREIGN KEY (module_id) REFERENCES master_modules(id) ON DELETE CASCADE, + UNIQUE(user_level_id, module_id, client_id) +); + +CREATE INDEX idx_user_level_module_accesses_user_level_id ON user_level_module_accesses(user_level_id); +CREATE INDEX idx_user_level_module_accesses_module_id ON user_level_module_accesses(module_id); +CREATE INDEX idx_user_level_module_accesses_client_id ON user_level_module_accesses(client_id); +CREATE INDEX idx_user_level_module_accesses_is_active ON user_level_module_accesses(is_active); + +COMMENT ON TABLE user_level_module_accesses IS 'Mengatur akses user_level ke modul-modul tertentu'; +COMMENT ON COLUMN user_level_module_accesses.can_access IS 'Apakah user level ini boleh akses modul ini'; + +-- Step 4: Migrate existing data (optional) +-- Copy existing menu.module_id relationship to menu_modules +INSERT INTO menu_modules (menu_id, module_id, position, client_id, is_active, created_at, updated_at) +SELECT + id as menu_id, + module_id, + 1 as position, + client_id, + is_active, + created_at, + updated_at +FROM master_menus +WHERE module_id IS NOT NULL +ON CONFLICT (menu_id, module_id, client_id) DO NOTHING; + +-- Success message +DO $$ +BEGIN + RAISE NOTICE 'Migration completed successfully!'; + RAISE NOTICE 'Tables created: menu_modules, user_level_module_accesses'; + RAISE NOTICE 'Column added: master_modules.action_type'; +END $$; +``` + +## ๐Ÿงช Testing the System + +### Test 1: Setup Test Data + +```sql +-- Create test modules +INSERT INTO master_modules (name, description, path_url, action_type, status_id) VALUES +('Test View', 'Test view module', '/test/view', 'view', 1), +('Test Create', 'Test create module', '/test/create', 'create', 1), +('Test Edit', 'Test edit module', '/test/edit', 'edit', 1); + +-- Create test menu +INSERT INTO master_menus (name, description, module_id, status_id) VALUES +('Test Menu', 'Test menu for module access', 1, 1); + +-- Link modules to menu +INSERT INTO menu_modules (menu_id, module_id, position) VALUES +(1, 1, 1), +(1, 2, 2), +(1, 3, 3); + +-- Grant access to user level +INSERT INTO user_level_module_accesses (user_level_id, module_id, can_access) VALUES +(1, 1, true), -- Level 1 can view +(1, 2, true), -- Level 1 can create +(2, 1, true); -- Level 2 can only view +``` + +### Test 2: Check Access via API + +```bash +# Check if user level 1 can access module 1 (should return true) +curl -X POST http://localhost:3000/api/user-level-module-accesses/check-access \ + -H "Authorization: Bearer {token}" \ + -H "Content-Type: application/json" \ + -d '{ + "user_level_id": 1, + "module_id": 1 + }' + +# Check if user level 2 can access module 2 (should return false) +curl -X POST http://localhost:3000/api/user-level-module-accesses/check-access \ + -H "Authorization: Bearer {token}" \ + -H "Content-Type: application/json" \ + -d '{ + "user_level_id": 2, + "module_id": 2 + }' +``` + +## ๐Ÿ“š Best Practices + +1. **Granular Modules**: Pisahkan setiap action menjadi modul terpisah untuk kontrol akses yang lebih baik +2. **Action Types**: Gunakan action_type konsisten: `view`, `create`, `edit`, `delete`, `approve`, `export` +3. **Batch Operations**: Gunakan batch endpoints untuk mengatur banyak akses sekaligus +4. **Middleware Layers**: Kombinasikan dengan middleware lain (auth, CSRF, rate limit) +5. **Audit Trail**: Log setiap akses yang ditolak untuk security monitoring +6. **Default Deny**: Jika tidak ada record di `user_level_module_accesses`, default adalah tidak ada akses + +## ๐ŸŽฏ Benefits + +โœ… **Kontrol Akses Granular** - Atur akses hingga level action (view, create, edit, delete) +โœ… **Fleksibel** - Mudah menambah/mengurangi modul tanpa mengubah kode +โœ… **Scalable** - Support multi-client/tenant +โœ… **Maintainable** - Struktur yang jelas dan terorganisir +โœ… **Secure** - Middleware otomatis block unauthorized access + +## ๐Ÿ”— Related Documentation + +- [Multi Client Access Guide](./MULTI_CLIENT_ACCESS_GUIDE.md) +- [Approval Workflow Architecture](../plan/approval-workflow-architecture.md) +- [API Documentation](./notes/api-endpoints-documentation.md) + diff --git a/docs/MENU_MODULE_QUICK_START.md b/docs/MENU_MODULE_QUICK_START.md new file mode 100644 index 0000000..73461ea --- /dev/null +++ b/docs/MENU_MODULE_QUICK_START.md @@ -0,0 +1,317 @@ +# Quick Start Guide - Menu Module Access System + +## ๐Ÿš€ Langkah-langkah Setup + +### 1. Jalankan Migration + +```bash +# Connect ke PostgreSQL dan jalankan migration +psql -U your_username -d your_database -f docs/migrations/004_add_menu_module_access_system.sql +``` + +### 2. Setup Data Master + +#### a. Buat Modules untuk Artikel + +```sql +INSERT INTO master_modules (name, description, path_url, action_type, status_id, is_active) VALUES +('Lihat Artikel', 'Melihat daftar dan detail artikel', '/api/articles', 'view', 1, true), +('Buat Artikel', 'Membuat artikel baru', '/api/articles', 'create', 1, true), +('Edit Artikel', 'Mengedit artikel yang ada', '/api/articles/:id', 'edit', 1, true), +('Hapus Artikel', 'Menghapus artikel', '/api/articles/:id', 'delete', 1, true), +('Approve Artikel', 'Menyetujui artikel', '/api/articles/:id/approve', 'approve', 1, true); +-- Dapatkan ID modules yang baru dibuat untuk step selanjutnya +``` + +#### b. Buat Menu Artikel + +```sql +INSERT INTO master_menus (name, description, module_id, icon, "group", position, status_id, is_active) +VALUES ('Artikel', 'Manajemen Artikel', 1, 'article-icon', 'Konten', 1, 1, true); +-- Dapatkan menu_id untuk step selanjutnya (misal: menu_id = 10) +``` + +#### c. Hubungkan Menu dengan Modules + +```sql +-- Asumsikan menu_id = 10, dan module_ids = 1,2,3,4,5 +INSERT INTO menu_modules (menu_id, module_id, position, is_active) VALUES +(10, 1, 1, true), -- Lihat +(10, 2, 2, true), -- Buat +(10, 3, 3, true), -- Edit +(10, 4, 4, true), -- Hapus +(10, 5, 5, true); -- Approve +``` + +#### d. Berikan Akses ke User Levels + +```sql +-- Admin Pusat (user_level_id = 1) - Full Access +INSERT INTO user_level_module_accesses (user_level_id, module_id, can_access, is_active) VALUES +(1, 1, true, true), -- Lihat +(1, 2, true, true), -- Buat +(1, 3, true, true), -- Edit +(1, 4, true, true), -- Hapus +(1, 5, true, true); -- Approve + +-- Editor (user_level_id = 2) - Lihat, Buat, Edit saja +INSERT INTO user_level_module_accesses (user_level_id, module_id, can_access, is_active) VALUES +(2, 1, true, true), -- Lihat +(2, 2, true, true), -- Buat +(2, 3, true, true); -- Edit + +-- Viewer (user_level_id = 3) - Lihat saja +INSERT INTO user_level_module_accesses (user_level_id, module_id, can_access, is_active) VALUES +(3, 1, true, true); -- Lihat +``` + +### 3. Implementasi di Code + +#### a. Tambahkan Routes dengan Middleware + +Buat file baru atau update: `app/router/article.routes.go` + +```go +package router + +import ( + "netidhub-saas-be/app/database" + "netidhub-saas-be/app/middleware" + "netidhub-saas-be/app/module/articles/controller" + + "github.com/gofiber/fiber/v2" +) + +func SetupArticleRoutes(app *fiber.App, db *database.Database, ctrl controller.ArticleController) { + // Initialize middlewares + authMw := middleware.NewUserMiddleware(db) + moduleAccessMw := middleware.NewModuleAccessMiddleware(db) + + // Article routes group + articles := app.Group("/api/articles") + + // GET /api/articles - View (module_id = 1) + articles.Get("/", + authMw.ValidateToken(), + moduleAccessMw.CheckModuleAccess(uint(1)), + ctrl.GetAll, + ) + + // GET /api/articles/:id - View detail (module_id = 1) + articles.Get("/:id", + authMw.ValidateToken(), + moduleAccessMw.CheckModuleAccess(uint(1)), + ctrl.GetOne, + ) + + // POST /api/articles - Create (module_id = 2) + articles.Post("/", + authMw.ValidateToken(), + moduleAccessMw.CheckModuleAccess(uint(2)), + ctrl.Create, + ) + + // PUT /api/articles/:id - Edit (module_id = 3) + articles.Put("/:id", + authMw.ValidateToken(), + moduleAccessMw.CheckModuleAccess(uint(3)), + ctrl.Update, + ) + + // DELETE /api/articles/:id - Delete (module_id = 4) + articles.Delete("/:id", + authMw.ValidateToken(), + moduleAccessMw.CheckModuleAccess(uint(4)), + ctrl.Delete, + ) + + // POST /api/articles/:id/approve - Approve (module_id = 5) + articles.Post("/:id/approve", + authMw.ValidateToken(), + moduleAccessMw.CheckModuleAccess(uint(5)), + ctrl.Approve, + ) +} +``` + +#### b. Register Routes di Main Router + +Update `app/router/api.go`: + +```go +// Import article routes +import ( + articleController "netidhub-saas-be/app/module/articles/controller" +) + +// Di dalam fungsi RegisterRoutes +func RegisterRoutes(app *fiber.App, db *database.Database) { + // ... existing routes ... + + // Article routes with module access control + articleCtrl := articleController.NewArticleController(articleService) + SetupArticleRoutes(app, db, articleCtrl) +} +``` + +### 4. Testing + +#### Test 1: User dengan Full Access (Admin) + +```bash +# Login sebagai admin (user_level_id = 1) +curl -X POST http://localhost:3000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "username": "admin", + "password": "password" + }' + +# Copy token dari response +TOKEN="your_admin_token_here" + +# Test akses semua endpoint - Semua harus berhasil +curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/articles +curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/articles -d '{"title":"Test"}' +curl -X PUT -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/articles/1 -d '{"title":"Updated"}' +curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/articles/1 +curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/articles/1/approve +``` + +#### Test 2: User dengan Limited Access (Editor) + +```bash +# Login sebagai editor (user_level_id = 2) +curl -X POST http://localhost:3000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "username": "editor", + "password": "password" + }' + +TOKEN="your_editor_token_here" + +# Test akses - View, Create, Edit harus berhasil +curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/articles # โœ“ Berhasil +curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/articles -d '{"title":"Test"}' # โœ“ Berhasil +curl -X PUT -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/articles/1 -d '{"title":"Updated"}' # โœ“ Berhasil + +# Test akses - Delete dan Approve harus ditolak +curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/articles/1 # โœ— 403 Forbidden +curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/articles/1/approve # โœ— 403 Forbidden +``` + +#### Test 3: Check Access via API + +```bash +# Check apakah user level 2 bisa akses module 4 (Delete) +curl -X POST http://localhost:3000/api/user-level-module-accesses/check-access \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "user_level_id": 2, + "module_id": 4 + }' + +# Expected response: +# { +# "success": true, +# "messages": ["Access check completed"], +# "data": { +# "has_access": false +# } +# } +``` + +### 5. Manage Access via API + +#### Berikan Akses Baru + +```bash +# Berikan akses Delete ke Editor (user_level_id = 2, module_id = 4) +curl -X POST http://localhost:3000/api/user-level-module-accesses \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "user_level_id": 2, + "module_id": 4, + "can_access": true + }' +``` + +#### Berikan Akses Multiple Modules Sekaligus + +```bash +# Berikan akses ke banyak modul sekaligus (batch) +curl -X POST http://localhost:3000/api/user-level-module-accesses/batch \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "user_level_id": 4, + "module_ids": [1, 2, 3], + "can_access": true + }' +``` + +#### Lihat Akses User Level + +```bash +# Lihat semua akses untuk user_level_id = 2 +curl -H "Authorization: Bearer $ADMIN_TOKEN" \ + "http://localhost:3000/api/user-level-module-accesses/user-level/2" +``` + +#### Cabut Akses + +```bash +# Update akses menjadi false (cabut akses) +curl -X PUT http://localhost:3000/api/user-level-module-accesses/123 \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "can_access": false + }' +``` + +## ๐Ÿ“‹ Checklist Implementation + +- [ ] Migration script sudah dijalankan +- [ ] Tabel `menu_modules` dan `user_level_module_accesses` sudah ada +- [ ] Modules sudah dibuat di `master_modules` +- [ ] Menu sudah dibuat di `master_menus` +- [ ] Menu-Module sudah dihubungkan di `menu_modules` +- [ ] User Level Access sudah dikonfigurasi di `user_level_module_accesses` +- [ ] Middleware `ModuleAccessMiddleware` sudah diterapkan di routes +- [ ] Testing berhasil untuk berbagai user level +- [ ] Dokumentasi internal sudah diupdate + +## ๐ŸŽฏ Tips & Best Practices + +1. **Konsisten dengan Action Type**: Gunakan standar `view`, `create`, `edit`, `delete`, `approve`, `export` +2. **Batch Operations**: Gunakan endpoint batch untuk setup awal atau bulk changes +3. **Soft Delete**: Gunakan `is_active=false` daripada hard delete +4. **Audit Log**: Log setiap perubahan access control untuk audit trail +5. **Default Deny**: Jika tidak ada record = tidak ada akses (secure by default) + +## โ“ Troubleshooting + +### Error: "User tidak valid" +- Pastikan middleware auth (`ValidateToken()`) dipanggil sebelum `CheckModuleAccess()` +- Pastikan user sudah login dan token valid + +### Error: "Module tidak ditemukan" +- Cek module_id yang digunakan di middleware +- Pastikan module exists dan `is_active = true` + +### Error: "Anda tidak memiliki akses ke modul ini" +- Cek `user_level_module_accesses` untuk user level tersebut +- Pastikan `can_access = true` dan `is_active = true` + +### User Level tidak sesuai +- Cek `user_roles.user_level_id` untuk user tersebut +- Pastikan relasi users -> user_roles -> user_levels sudah benar + +## ๐Ÿ“š Selanjutnya + +Baca dokumentasi lengkap di: [MENU_MODULE_ACCESS_SYSTEM.md](./MENU_MODULE_ACCESS_SYSTEM.md) + diff --git a/docs/migrations/004_add_menu_module_access_system.sql b/docs/migrations/004_add_menu_module_access_system.sql new file mode 100644 index 0000000..08d09cb --- /dev/null +++ b/docs/migrations/004_add_menu_module_access_system.sql @@ -0,0 +1,199 @@ +-- Migration: Add Menu Module Access System +-- Description: Enhance master_modules and create menu_modules & user_level_module_accesses tables +-- Date: 2026-01-15 + +-- ============================================================================ +-- Step 1: Enhance master_modules with action_type +-- ============================================================================ +ALTER TABLE master_modules +ADD COLUMN IF NOT EXISTS action_type VARCHAR NULL; + +COMMENT ON COLUMN master_modules.action_type IS 'Tipe aksi: view, create, edit, delete, approve, export, etc'; + +-- ============================================================================ +-- Step 2: Create menu_modules table +-- ============================================================================ +CREATE TABLE IF NOT EXISTS menu_modules ( + id SERIAL PRIMARY KEY, + menu_id INT NOT NULL, + module_id INT NOT NULL, + position INT NULL, + client_id UUID NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + FOREIGN KEY (menu_id) REFERENCES master_menus(id) ON DELETE CASCADE, + FOREIGN KEY (module_id) REFERENCES master_modules(id) ON DELETE CASCADE +); + +-- Add unique constraint with client_id consideration +CREATE UNIQUE INDEX idx_menu_modules_unique +ON menu_modules(menu_id, module_id, COALESCE(client_id, '00000000-0000-0000-0000-000000000000'::UUID)); + +-- Add other indexes for performance +CREATE INDEX idx_menu_modules_menu_id ON menu_modules(menu_id); +CREATE INDEX idx_menu_modules_module_id ON menu_modules(module_id); +CREATE INDEX idx_menu_modules_client_id ON menu_modules(client_id) WHERE client_id IS NOT NULL; +CREATE INDEX idx_menu_modules_is_active ON menu_modules(is_active); + +-- Add comments +COMMENT ON TABLE menu_modules IS 'Relasi many-to-many antara menu dan modul. Satu menu bisa punya banyak modul.'; +COMMENT ON COLUMN menu_modules.position IS 'Urutan modul dalam menu untuk sorting'; +COMMENT ON COLUMN menu_modules.menu_id IS 'Foreign key ke master_menus'; +COMMENT ON COLUMN menu_modules.module_id IS 'Foreign key ke master_modules'; + +-- ============================================================================ +-- Step 3: Create user_level_module_accesses table +-- ============================================================================ +CREATE TABLE IF NOT EXISTS user_level_module_accesses ( + id SERIAL PRIMARY KEY, + user_level_id INT NOT NULL, + module_id INT NOT NULL, + can_access BOOLEAN DEFAULT TRUE, + client_id UUID NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + FOREIGN KEY (user_level_id) REFERENCES user_levels(id) ON DELETE CASCADE, + FOREIGN KEY (module_id) REFERENCES master_modules(id) ON DELETE CASCADE +); + +-- Add unique constraint with client_id consideration +CREATE UNIQUE INDEX idx_user_level_module_accesses_unique +ON user_level_module_accesses(user_level_id, module_id, COALESCE(client_id, '00000000-0000-0000-0000-000000000000'::UUID)); + +-- Add other indexes for performance +CREATE INDEX idx_user_level_module_accesses_user_level_id ON user_level_module_accesses(user_level_id); +CREATE INDEX idx_user_level_module_accesses_module_id ON user_level_module_accesses(module_id); +CREATE INDEX idx_user_level_module_accesses_client_id ON user_level_module_accesses(client_id) WHERE client_id IS NOT NULL; +CREATE INDEX idx_user_level_module_accesses_is_active ON user_level_module_accesses(is_active); +CREATE INDEX idx_user_level_module_accesses_can_access ON user_level_module_accesses(can_access); + +-- Add comments +COMMENT ON TABLE user_level_module_accesses IS 'Mengatur akses user_level ke modul-modul tertentu. Kontrol akses granular per modul.'; +COMMENT ON COLUMN user_level_module_accesses.can_access IS 'Apakah user level ini boleh akses modul ini. True=boleh, False=tidak boleh'; +COMMENT ON COLUMN user_level_module_accesses.user_level_id IS 'Foreign key ke user_levels'; +COMMENT ON COLUMN user_level_module_accesses.module_id IS 'Foreign key ke master_modules'; + +-- ============================================================================ +-- Step 4: Migrate existing data (optional - only if you have existing data) +-- ============================================================================ +-- Copy existing menu.module_id relationship to menu_modules +-- This creates initial menu-module relationships from existing master_menus +INSERT INTO menu_modules (menu_id, module_id, position, client_id, is_active, created_at, updated_at) +SELECT + id as menu_id, + module_id, + position as position, -- Use existing position if available + client_id, + is_active, + created_at, + updated_at +FROM master_menus +WHERE module_id IS NOT NULL +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- Step 5: Insert sample modules for testing (optional) +-- ============================================================================ +-- You can uncomment this section if you want sample data + +/* +-- Sample modules for Article Management +INSERT INTO master_modules (name, description, path_url, action_type, status_id, is_active) VALUES +('View Articles', 'View article list and details', '/api/articles', 'view', 1, true), +('Create Article', 'Create new article', '/api/articles/create', 'create', 1, true), +('Edit Article', 'Edit existing article', '/api/articles/edit', 'edit', 1, true), +('Delete Article', 'Delete article', '/api/articles/delete', 'delete', 1, true), +('Approve Article', 'Approve article submission', '/api/articles/approve', 'approve', 1, true), +('Export Articles', 'Export articles to file', '/api/articles/export', 'export', 1, true) +ON CONFLICT DO NOTHING; + +-- Sample modules for User Management +INSERT INTO master_modules (name, description, path_url, action_type, status_id, is_active) VALUES +('View Users', 'View user list and details', '/api/users', 'view', 1, true), +('Create User', 'Create new user', '/api/users/create', 'create', 1, true), +('Edit User', 'Edit existing user', '/api/users/edit', 'edit', 1, true), +('Delete User', 'Delete user', '/api/users/delete', 'delete', 1, true) +ON CONFLICT DO NOTHING; +*/ + +-- ============================================================================ +-- Step 6: Verification queries +-- ============================================================================ +-- Run these queries to verify the migration + +-- Check if tables exist +DO $$ +DECLARE + v_menu_modules_exists BOOLEAN; + v_user_level_module_accesses_exists BOOLEAN; + v_action_type_exists BOOLEAN; +BEGIN + -- Check menu_modules table + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'menu_modules' + ) INTO v_menu_modules_exists; + + -- Check user_level_module_accesses table + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'user_level_module_accesses' + ) INTO v_user_level_module_accesses_exists; + + -- Check action_type column + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'master_modules' + AND column_name = 'action_type' + ) INTO v_action_type_exists; + + -- Report results + RAISE NOTICE '========================================'; + RAISE NOTICE 'Migration Verification Results:'; + RAISE NOTICE '========================================'; + + IF v_menu_modules_exists THEN + RAISE NOTICE 'โœ“ Table menu_modules created successfully'; + ELSE + RAISE WARNING 'โœ— Table menu_modules NOT created'; + END IF; + + IF v_user_level_module_accesses_exists THEN + RAISE NOTICE 'โœ“ Table user_level_module_accesses created successfully'; + ELSE + RAISE WARNING 'โœ— Table user_level_module_accesses NOT created'; + END IF; + + IF v_action_type_exists THEN + RAISE NOTICE 'โœ“ Column action_type added to master_modules'; + ELSE + RAISE WARNING 'โœ— Column action_type NOT added to master_modules'; + END IF; + + RAISE NOTICE '========================================'; + + IF v_menu_modules_exists AND v_user_level_module_accesses_exists AND v_action_type_exists THEN + RAISE NOTICE 'Migration completed successfully! โœ“'; + ELSE + RAISE WARNING 'Migration completed with errors! Please check above.'; + END IF; + + RAISE NOTICE '========================================'; +END $$; + +-- Show table statistics +SELECT + 'menu_modules' as table_name, + COUNT(*) as row_count +FROM menu_modules +UNION ALL +SELECT + 'user_level_module_accesses' as table_name, + COUNT(*) as row_count +FROM user_level_module_accesses; + diff --git a/test-build.exe b/test-build.exe new file mode 100644 index 0000000..130a087 Binary files /dev/null and b/test-build.exe differ