Files
fm_be/internal/transport/http/handlers/role_handler.go
2026-07-16 22:16:45 +07:00

1030 lines
33 KiB
Go

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
}