package handlers import ( "errors" "sort" "strconv" "strings" "time" "github.com/gofiber/fiber/v2" "gorm.io/gorm" "wucher/internal/domain/auth" "wucher/internal/service" "wucher/internal/shared/pkg/apperrorsx" "wucher/internal/shared/pkg/uuidv7" "wucher/internal/transport/http/dto" "wucher/internal/transport/http/jsonapi" "wucher/internal/transport/http/response" "wucher/internal/transport/http/validators" ) type RoleHandler struct { svc *service.RoleService validate *validators.Validator } func NewRoleHandler(svc *service.RoleService) *RoleHandler { return &RoleHandler{ svc: svc, validate: validators.New(), } } // CreateRole godoc // @Summary Create role // @Description Create a new role. Name is required and must be unique. // @Tags Roles // @Accept json // @Produce json // @Param request body dto.RoleCreateRequest true "JSON:API Role create request" // @Success 201 {object} dto.RoleResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/roles/create [post] func (h *RoleHandler) Create(c *fiber.Ctx) error { var req dto.RoleCreateRequest if err := c.BodyParser(&req); err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, errs) } role := &auth.Role{ Name: strings.TrimSpace(req.Data.Attributes.Name), Description: strings.TrimSpace(req.Data.Attributes.Description), CreatedBy: actorUserID(c), } if err := h.svc.Create(c.UserContext(), role); err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Create failed", Detail: safeInternalDetail(), }}) } return response.Write(c, fiber.StatusCreated, roleResource(role)) } // UpdateRole godoc // @Summary Update role (partial) // @Description Patch role by ID. Provide at least one of: name, description. Name must be unique if provided. // @Tags Roles // @Accept json // @Produce json // @Param uuid path string true "Role UUID (UUIDv7)" // @Param request body dto.RoleUpdateRequest true "JSON:API Role update request" // @Success 200 {object} dto.RoleResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Router /api/v1/roles/update/{uuid} [patch] func (h *RoleHandler) Update(c *fiber.Ctx) error { uuidStr := c.Params("uuid") roleID, err := uuidv7.ParseString(uuidStr) if err != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "uuid is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"}, }}) } var req dto.RoleUpdateRequest if err := c.BodyParser(&req); err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, errs) } if strings.TrimSpace(req.Data.ID) == "" { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is required", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) } if req.Data.ID != uuidStr { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id does not match path uuid", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) } if req.Data.Attributes.Name == nil && req.Data.Attributes.Description == nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "at least one attribute must be provided", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes"}, }}) } existing, err := h.svc.GetByID(c.UserContext(), roleID) if err != nil || existing == nil { return writeRoleErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "role not found", }}) } if req.Data.Attributes.Name != nil { name := strings.TrimSpace(*req.Data.Attributes.Name) if name == "" { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "name cannot be empty", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/name"}, }}) } existing.Name = name } if req.Data.Attributes.Description != nil { existing.Description = strings.TrimSpace(*req.Data.Attributes.Description) } if err := h.svc.Update(c.UserContext(), existing); err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Update failed", Detail: safeInternalDetail(), }}) } return response.Write(c, fiber.StatusOK, roleResource(existing)) } // DeleteRole godoc // @Summary Delete role // @Tags Roles // @Produce json // @Param uuid path string true "Role UUID (UUIDv7)" // @Success 200 {object} dto.GenericDeleteResponse // @Failure 404 {object} dto.ErrorResponse // @Router /api/v1/roles/delete/{uuid} [delete] func (h *RoleHandler) Delete(c *fiber.Ctx) error { uuidStr := c.Params("uuid") roleID, err := uuidv7.ParseString(uuidStr) if err != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "uuid is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"}, }}) } if err := h.svc.Delete(c.UserContext(), roleID); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return writeRoleErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "role not found", }}) } return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Delete failed", Detail: safeInternalDetail(), }}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "role_delete", Attributes: map[string]any{ "deleted": true, }, }) } // AssignPermission godoc // @Summary Assign permission to role // @Description Assign one permission to a role. Idempotent if already assigned. // @Tags Roles // @Accept json // @Produce json // @Param uuid path string true "Role UUID (UUIDv7)" // @Param request body dto.RolePermissionAssignRequest true "JSON:API role permission assign request" // @Success 200 {object} dto.RolePermissionResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Router /api/v1/roles/{uuid}/permissions [post] func (h *RoleHandler) AssignPermission(c *fiber.Ctx) error { uuidStr := c.Params("uuid") roleID, err := uuidv7.ParseString(uuidStr) if err != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "uuid is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"}, }}) } var req dto.RolePermissionAssignRequest if err := c.BodyParser(&req); err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, errs) } permissionID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.PermissionID)) if err != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "permission_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/permission_id"}, }}) } role, err := h.svc.GetByID(c.UserContext(), roleID) if err != nil || role == nil { return writeRoleErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "role not found", }}) } perm, err := h.svc.GetPermissionByID(c.UserContext(), permissionID) if err != nil || perm == nil { return writeRoleErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "permission not found", }}) } rp, err := h.svc.AssignPermission(c.UserContext(), roleID, permissionID) if err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Assign failed", Detail: safeInternalDetail(), }}) } return response.Write(c, fiber.StatusOK, rolePermissionResource(rp)) } // RemovePermission godoc // @Summary Remove permission from role // @Description Remove one permission from role. // @Tags Roles // @Produce json // @Param uuid path string true "Role UUID (UUIDv7)" // @Param permission_uuid path string true "Permission UUID (UUIDv7)" // @Success 200 {object} dto.RolePermissionResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Router /api/v1/roles/{uuid}/permissions/{permission_uuid} [delete] func (h *RoleHandler) RemovePermission(c *fiber.Ctx) error { uuidStr := c.Params("uuid") roleID, err := uuidv7.ParseString(uuidStr) if err != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "uuid is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"}, }}) } permissionIDStr := c.Params("permission_uuid") permissionID, err := uuidv7.ParseString(permissionIDStr) if err != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "permission_uuid is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/permission_uuid"}, }}) } role, err := h.svc.GetByID(c.UserContext(), roleID) if err != nil || role == nil { return writeRoleErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "role not found", }}) } if err := h.svc.RemovePermission(c.UserContext(), roleID, permissionID); err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Remove failed", Detail: safeInternalDetail(), }}) } rp := &auth.RolePermission{ RoleID: roleID, PermissionID: permissionID, } return response.Write(c, fiber.StatusOK, rolePermissionResource(rp)) } // AssignPermissionsBulk godoc // @Summary Assign permissions to role (bulk) // @Description Assign multiple permissions to role. Idempotent for duplicates/already assigned. // @Tags Roles // @Accept json // @Produce json // @Param uuid path string true "Role UUID (UUIDv7)" // @Param request body dto.RolePermissionAssignBulkRequest true "JSON:API role permission bulk request" // @Success 200 {object} dto.RolePermissionListResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Router /api/v1/roles/{uuid}/permissions/assign-bulk [post] func (h *RoleHandler) AssignPermissionsBulk(c *fiber.Ctx) error { uuidStr := c.Params("uuid") roleID, err := uuidv7.ParseString(uuidStr) if err != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "uuid is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"}, }}) } var req dto.RolePermissionAssignBulkRequest if err := c.BodyParser(&req); err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, errs) } if len(req.Data.Attributes.PermissionIDs) == 0 { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "permission_ids must not be empty", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/permission_ids"}, }}) } role, err := h.svc.GetByID(c.UserContext(), roleID) if err != nil || role == nil { return writeRoleErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "role not found", }}) } seen := map[string]struct{}{} out := make([]dto.RolePermissionResource, 0, len(req.Data.Attributes.PermissionIDs)) for i, raw := range req.Data.Attributes.PermissionIDs { pidStr := strings.TrimSpace(raw) if pidStr == "" { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "permission_id cannot be empty", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/permission_ids/" + parseIntToString(i)}, }}) } if _, ok := seen[pidStr]; ok { continue } seen[pidStr] = struct{}{} permissionID, err := uuidv7.ParseString(pidStr) if err != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "permission_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/permission_ids/" + parseIntToString(i)}, }}) } perm, err := h.svc.GetPermissionByID(c.UserContext(), permissionID) if err != nil || perm == nil { return writeRoleErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "permission not found", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/permission_ids/" + parseIntToString(i)}, }}) } rp, err := h.svc.AssignPermission(c.UserContext(), roleID, permissionID) if err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Assign failed", Detail: safeInternalDetail(), }}) } out = append(out, rolePermissionResource(rp)) } return response.Write(c, fiber.StatusOK, out) } // RemovePermissionsBulk godoc // @Summary Remove permissions from role (bulk) // @Description Remove multiple permissions from role. // @Tags Roles // @Accept json // @Produce json // @Param uuid path string true "Role UUID (UUIDv7)" // @Param request body dto.RolePermissionRemoveBulkRequest true "JSON:API role permission bulk request" // @Success 200 {object} dto.RolePermissionListResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Router /api/v1/roles/{uuid}/permissions/remove-bulk [post] func (h *RoleHandler) RemovePermissionsBulk(c *fiber.Ctx) error { uuidStr := c.Params("uuid") roleID, err := uuidv7.ParseString(uuidStr) if err != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "uuid is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"}, }}) } var req dto.RolePermissionRemoveBulkRequest if err := c.BodyParser(&req); err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, errs) } if len(req.Data.Attributes.PermissionIDs) == 0 { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "permission_ids must not be empty", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/permission_ids"}, }}) } role, err := h.svc.GetByID(c.UserContext(), roleID) if err != nil || role == nil { return writeRoleErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "role not found", }}) } seen := map[string]struct{}{} out := make([]dto.RolePermissionResource, 0, len(req.Data.Attributes.PermissionIDs)) for i, raw := range req.Data.Attributes.PermissionIDs { pidStr := strings.TrimSpace(raw) if pidStr == "" { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "permission_id cannot be empty", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/permission_ids/" + parseIntToString(i)}, }}) } if _, ok := seen[pidStr]; ok { continue } seen[pidStr] = struct{}{} permissionID, err := uuidv7.ParseString(pidStr) if err != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "permission_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/permission_ids/" + parseIntToString(i)}, }}) } if err := h.svc.RemovePermission(c.UserContext(), roleID, permissionID); err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Remove failed", Detail: safeInternalDetail(), }}) } out = append(out, rolePermissionResource(&auth.RolePermission{ RoleID: roleID, PermissionID: permissionID, })) } return response.Write(c, fiber.StatusOK, out) } // UpdatePermission godoc // @Summary Update permission // @Description Patch permission by ID. Currently supports updating requires_pin. // @Tags Roles // @Accept json // @Produce json // @Param permission_uuid path string true "Permission UUID (UUIDv7)" // @Param request body dto.PermissionUpdateRequest true "JSON:API permission update request" // @Success 200 {object} dto.PermissionResource // @Failure 422 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Router /api/v1/roles/permissions/{permission_uuid} [patch] func (h *RoleHandler) UpdatePermission(c *fiber.Ctx) error { permissionUUID := c.Params("permission_uuid") permissionID, err := uuidv7.ParseString(permissionUUID) if err != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "permission_uuid is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/permission_uuid"}, }}) } var req dto.PermissionUpdateRequest if err := c.BodyParser(&req); err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, errs) } if strings.TrimSpace(req.Data.ID) == "" { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is required", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) } if req.Data.ID != permissionUUID { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id does not match path permission_uuid", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) } if req.Data.Attributes.RequiresPIN == nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "requires_pin is required", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/requires_pin"}, }}) } perm, err := h.svc.GetPermissionByID(c.UserContext(), permissionID) if err != nil || perm == nil { return writeRoleErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "permission not found", }}) } perm.RequiresPIN = *req.Data.Attributes.RequiresPIN if err := h.svc.UpdatePermission(c.UserContext(), perm); err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Update failed", Detail: safeInternalDetail(), }}) } return response.Write(c, fiber.StatusOK, permissionResource(perm)) } // GetRoleByID godoc // @Summary Get role by ID // @Tags Roles // @Produce json // @Param uuid path string true "Role UUID (UUIDv7)" // @Success 200 {object} dto.RoleResponse // @Failure 404 {object} dto.ErrorResponse // @Router /api/v1/roles/get/{uuid} [get] func (h *RoleHandler) GetByID(c *fiber.Ctx) error { uuidStr := c.Params("uuid") roleID, err := uuidv7.ParseString(uuidStr) if err != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "uuid is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"}, }}) } role, err := h.svc.GetByID(c.UserContext(), roleID) if err != nil || role == nil { return writeRoleErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "role not found", }}) } return response.Write(c, fiber.StatusOK, roleResource(role)) } // ListRolePermissions godoc // @Summary List a role's assigned permissions // @Description Returns the permissions currently assigned to the given role. Pair with /roles/permissions/get-all to pre-check a role-permission editor. // @Tags Roles // @Produce json // @Param uuid path string true "Role UUID (UUIDv7)" // @Success 200 {object} dto.PermissionListResponse // @Router /api/v1/roles/{uuid}/permissions [get] func (h *RoleHandler) ListRolePermissions(c *fiber.Ctx) error { uuidStr := c.Params("uuid") roleID, err := uuidv7.ParseString(uuidStr) if err != nil { return writeRoleErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "uuid is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"}, }}) } perms, err := h.svc.ListPermissionsByRoleID(c.UserContext(), roleID) if err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: safeInternalDetail(), }}) } data := make([]dto.PermissionResource, 0, len(perms)) for i := range perms { data = append(data, permissionResource(&perms[i])) } return response.Write(c, fiber.StatusOK, data) } // ListPermissions godoc // @Summary List permissions grouped by module // @Description Returns every permission grouped by its module, ordered by the module registry. Optional filter[name] narrows by permission name/key. // @Tags Roles // @Produce json // @Param filter[name] query string false "Filter by name/key (contains)" // @Success 200 {object} dto.PermissionGroupedResponse // @Router /api/v1/roles/permissions/get-all [get] func (h *RoleHandler) ListPermissions(c *fiber.Ctx) error { perms, _, err := h.svc.ListPermissions(c.UserContext(), c.Query("filter[name]"), "", 0, 0) if err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: safeInternalDetail(), }}) } return response.Write(c, fiber.StatusOK, groupPermissionsByModule(perms)) } func groupPermissionsByModule(perms []auth.Permission) []dto.PermissionGroupResource { byModule := make(map[string][]dto.PermissionResource) for i := range perms { module := perms[i].Module if module == "" { module = auth.ModuleForKey(perms[i].Key) } byModule[module] = append(byModule[module], permissionResource(&perms[i])) } modules := make([]string, 0, len(byModule)) for module := range byModule { modules = append(modules, module) } sort.Slice(modules, func(i, j int) bool { oi, oj := auth.ModuleSortIndex(modules[i]), auth.ModuleSortIndex(modules[j]) if oi != oj { return oi < oj } return modules[i] < modules[j] }) groups := make([]dto.PermissionGroupResource, 0, len(modules)) for _, module := range modules { items := byModule[module] sort.Slice(items, func(i, j int) bool { return items[i].Attributes.Key < items[j].Attributes.Key }) groups = append(groups, dto.PermissionGroupResource{ Module: module, ModuleLabel: auth.ModuleLabel(module), Permissions: items, }) } return groups } // ListRoles godoc // @Summary List roles // @Description JSON:API list with pagination, filtering and sorting. Sort values: name, -name, created_at, -created_at, updated_at, -updated_at. // @Tags Roles // @Produce json // @Param filter[name] query string false "Filter by name (contains)" // @Param page[number] query int false "Page number (default 1)" // @Param page[size] query int false "Page size (default 20, max 100)" // @Param sort query string false "Sort order" // @Param include query string false "Set to 'permissions' to embed each role's assigned permissions" // @Success 200 {object} dto.RoleListResponse // @Router /api/v1/roles/get-all [get] func (h *RoleHandler) List(c *fiber.Ctx) error { filterName := c.Query("filter[name]") pageNumber := parseIntDefault(c.Query("page[number]"), 1) pageSize := parseIntDefault(c.Query("page[size]"), 20) if pageSize > 100 { pageSize = 100 } if pageNumber < 1 { pageNumber = 1 } offset := (pageNumber - 1) * pageSize _ = offset sort := c.Query("sort") roles, total, err := h.svc.List(c.UserContext(), filterName, sort, 0, 0) if err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: safeInternalDetail(), }}) } withPermissions := strings.Contains(c.Query("include"), "permissions") data := make([]dto.RoleResource, 0, len(roles)) for i := range roles { res := roleResource(&roles[i]) if withPermissions { perms, permErr := h.svc.ListPermissionsByRoleID(c.UserContext(), roles[i].ID) if permErr != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: safeInternalDetail(), }}) } res.Permissions = make([]dto.PermissionResource, 0, len(perms)) for j := range perms { res.Permissions = append(res.Permissions, permissionResource(&perms[j])) } } data = append(data, res) } meta := map[string]any{ "page_number": pageNumber, "page_size": pageSize, "total": total, } return response.WriteWithMeta(c, fiber.StatusOK, data, meta) } // ListRolesDatatable godoc // @Summary List roles (datatable) // @Description Datatable response with simple pagination params. // @Tags Roles // @Produce json // @Param page query int false "Page number (default 1)" // @Param size query int false "Page size (default 20, max 100)" // @Param limit query int false "Page size override (same as size)" // @Param draw query int false "Draw counter (optional)" // @Param search query string false "Search term" // @Success 200 {object} dto.RoleDataTableResponse // @Router /api/v1/roles/get-all/dt [get] func (h *RoleHandler) ListDatatable(c *fiber.Ctx) error { pageNumber := parseIntDefault(c.Query("page"), 1) if pageNumber < 1 { pageNumber = 1 } length := parseIntDefault(c.Query("limit"), 0) if length <= 0 { length = parseIntDefault(c.Query("size"), 20) } if length > 100 { length = 100 } if length < 1 { length = 20 } start := (pageNumber - 1) * length draw := parseIntDefault(c.Query("draw"), pageNumber) search := c.Query("search") roles, total, err := h.svc.List(c.UserContext(), search, "", length, start) if err != nil { return writeRoleErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: safeInternalDetail(), }}) } data := make([]dto.RoleResource, 0, len(roles)) for i := range roles { data = append(data, roleResource(&roles[i])) } meta := map[string]any{ "draw": draw, "records_total": total, "records_filtered": total, } return response.WriteWithMeta(c, fiber.StatusOK, data, meta) } func roleResource(role *auth.Role) dto.RoleResource { id, _ := uuidv7.BytesToString(role.ID) return dto.RoleResource{ Type: "role", ID: id, Attributes: dto.RoleAttributes{ Name: role.Name, Description: role.Description, CreatedBy: uuidStringOrEmpty(role.CreatedBy), CreatedAt: role.CreatedAt.UTC().Format(time.RFC3339), UpdatedAt: role.UpdatedAt.UTC().Format(time.RFC3339), }, } } func rolePermissionResource(rp *auth.RolePermission) dto.RolePermissionResource { id, _ := uuidv7.BytesToString(rp.ID) roleID, _ := uuidv7.BytesToString(rp.RoleID) permissionID, _ := uuidv7.BytesToString(rp.PermissionID) return dto.RolePermissionResource{ Type: "role_permission", ID: id, Attributes: dto.RolePermissionAttributes{ RoleID: roleID, PermissionID: permissionID, }, } } func permissionResource(permission *auth.Permission) dto.PermissionResource { id, _ := uuidv7.BytesToString(permission.ID) return dto.PermissionResource{ Type: "permission", ID: id, Attributes: dto.PermissionAttributes{ Name: permission.Name, Key: permission.Key, Description: permission.Description, RequiresPIN: permission.RequiresPIN, CreatedAt: permission.CreatedAt.UTC().Format(time.RFC3339), UpdatedAt: permission.UpdatedAt.UTC().Format(time.RFC3339), }, } } func parseIntToString(i int) string { if i == 0 { return "0" } buf := make([]byte, 0, 12) for i > 0 { buf = append(buf, byte('0'+i%10)) i /= 10 } for left, right := 0, len(buf)-1; left < right; left, right = left+1, right-1 { buf[left], buf[right] = buf[right], buf[left] } return string(buf) } func writeRoleErrors(c *fiber.Ctx, status int, errs []jsonapi.ErrorObject) error { enriched := make([]jsonapi.ErrorObject, 0, len(errs)) statusStr := strconv.Itoa(status) for i := range errs { e := errs[i] e.Status = statusStr def := inferRoleDef(status, e.Title, e.Detail) if strings.TrimSpace(e.Code) == "" { e.Code = def.Code } if strings.TrimSpace(e.ErrorCode) == "" { e.ErrorCode = def.ErrorCode } if strings.TrimSpace(e.Title) == "" { e.Title = def.Title } e.Detail = def.Message enriched = append(enriched, e) } return response.WriteErrors(c, status, enriched) } func inferRoleDef(status int, title, detail string) apperrorsx.Def { lowerTitle := strings.ToLower(strings.TrimSpace(title)) lowerDetail := strings.ToLower(strings.TrimSpace(detail)) switch status { case fiber.StatusNotFound: if strings.Contains(lowerDetail, "permission not found") { return apperrorsx.ErrRolePermissionNotFound } return apperrorsx.ErrRoleNotFound case fiber.StatusConflict: return apperrorsx.ErrRoleRelationDelete case fiber.StatusUnprocessableEntity: switch { case strings.Contains(lowerDetail, "permission_uuid is invalid"), strings.Contains(lowerDetail, "permission_id is invalid uuid"): return apperrorsx.ErrRoleInvalidPermissionUUID case strings.Contains(lowerDetail, "uuid is invalid"): return apperrorsx.ErrRoleInvalidUUID case strings.Contains(lowerDetail, "id is required"): return apperrorsx.ErrRoleIDRequired case strings.Contains(lowerDetail, "id does not match"): return apperrorsx.ErrRoleIDMismatch case strings.Contains(lowerDetail, "at least one attribute"): return apperrorsx.ErrRoleAttributesRequired case strings.Contains(lowerDetail, "name cannot be empty"): return apperrorsx.ErrRoleNameRequired case strings.Contains(lowerDetail, "permission_id cannot be empty"): return apperrorsx.ErrRolePermissionIDRequired case strings.Contains(lowerDetail, "permission_ids must not be empty"): return apperrorsx.ErrRolePermissionIDsEmpty case strings.Contains(lowerDetail, "requires_pin is required"): return apperrorsx.ErrRolePermissionRequiresPINReq default: return apperrorsx.ErrRoleInvalidPayload } case fiber.StatusBadRequest: switch { case strings.Contains(lowerTitle, "invalid json"): return apperrorsx.ErrRoleInvalidJSON case strings.Contains(lowerTitle, "create failed"): return apperrorsx.ErrRoleCreateFailed case strings.Contains(lowerTitle, "update failed"): return apperrorsx.ErrRoleUpdateFailed case strings.Contains(lowerTitle, "delete failed"): return apperrorsx.ErrRoleDeleteFailed case strings.Contains(lowerTitle, "assign failed"): return apperrorsx.ErrRoleAssignFailed case strings.Contains(lowerTitle, "remove failed"): return apperrorsx.ErrRoleRemoveFailed case strings.Contains(lowerTitle, "list failed"): if strings.Contains(lowerDetail, "permission") { return apperrorsx.ErrRolePermissionListFailed } return apperrorsx.ErrRoleListFailed default: if strings.Contains(lowerTitle, "update failed") && strings.Contains(lowerDetail, "permission") { return apperrorsx.ErrRolePermissionUpdateFailed } return apperrorsx.ErrRoleInvalidPayload } default: return apperrorsx.ErrInternal } } func inferRoleErrorCode(status int, title, detail string) (string, string) { def := inferRoleDef(status, title, detail) return def.Code, def.ErrorCode }