Skip to content

Commit 65ed904

Browse files
committed
[server] add user-related endpoints
1 parent 53cfc09 commit 65ed904

8 files changed

Lines changed: 1160 additions & 2 deletions

File tree

internal/server/admin/users/dto.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package users
2+
3+
import (
4+
"time"
5+
6+
"github.com/bit-issues/backend/internal/users"
7+
)
8+
9+
const (
10+
defaultLimit = 20
11+
)
12+
13+
// AdminUserFilter represents query parameters for filtering users.
14+
//
15+
// @Description Query parameters for filtering and paginating users list.
16+
type AdminUserFilter struct {
17+
Status *users.Status `query:"status" validate:"omitempty,oneof=pending active blocked" enums:"pending,active,blocked"`
18+
Role *users.Role `query:"role" validate:"omitempty,oneof=admin user" enums:"admin,user"`
19+
Limit int `query:"limit" validate:"min=1,max=100" default:"20"`
20+
Offset int `query:"offset" validate:"min=0" default:"0"`
21+
}
22+
23+
// UpdateUserRequest represents admin request to update user status/role.
24+
//
25+
// @Description Admin can update user status (active/blocked/pending) and role (admin/user).
26+
type UpdateUserRequest struct {
27+
Status *users.Status `json:"status,omitempty" validate:"omitempty,oneof=pending active blocked" enums:"pending,active,blocked"`
28+
Role *users.Role `json:"role,omitempty" validate:"omitempty,oneof=admin user" enums:"admin,user"`
29+
}
30+
31+
// UserResponse represents user data in admin responses (without password).
32+
//
33+
// @Description User data returned in admin API responses (password excluded).
34+
type UserResponse struct {
35+
ID int64 `json:"id" example:"42"`
36+
Email string `json:"email" example:"user@example.com"`
37+
Role users.Role `json:"role" example:"user" enums:"admin,user"`
38+
Status users.Status `json:"status" example:"active" enums:"pending,active,blocked"`
39+
CreatedAt time.Time `json:"created_at" example:"2026-04-06T10:00:00Z"`
40+
UpdatedAt time.Time `json:"updated_at" example:"2026-04-06T10:00:00Z"`
41+
}
42+
43+
// UserListResponse represents paginated list of users.
44+
//
45+
// @Description Paginated response containing users list and total count.
46+
type UserListResponse struct {
47+
Items []UserResponse `json:"items"`
48+
Total int `json:"total" example:"42"`
49+
}
50+
51+
func defaultAdminUserFilter() AdminUserFilter {
52+
return AdminUserFilter{
53+
Status: nil,
54+
Role: nil,
55+
Limit: defaultLimit,
56+
Offset: 0,
57+
}
58+
}
59+
60+
// toUserResponse converts domain User to admin UserResponse.
61+
func toUserResponse(u *users.User) UserResponse {
62+
return UserResponse{
63+
ID: u.ID,
64+
Email: u.Email,
65+
Role: u.Role,
66+
Status: u.Status,
67+
CreatedAt: u.CreatedAt,
68+
UpdatedAt: u.UpdatedAt,
69+
}
70+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package users
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strconv"
7+
8+
"github.com/bit-issues/backend/internal/jwt"
9+
"github.com/bit-issues/backend/internal/server/middlewares/jwtauth"
10+
"github.com/bit-issues/backend/internal/users"
11+
"github.com/go-core-fx/fiberfx/handler"
12+
"github.com/go-core-fx/fiberfx/validation"
13+
"github.com/go-playground/validator/v10"
14+
"github.com/gofiber/fiber/v2"
15+
)
16+
17+
type Handler struct {
18+
handler.Base
19+
20+
usersSvc *users.Service
21+
22+
jwtSvc *jwt.Service
23+
}
24+
25+
func NewHandler(usersSvc *users.Service, jwtSvc *jwt.Service, validate *validator.Validate) handler.Handler {
26+
return &Handler{
27+
Base: handler.Base{Validator: validate},
28+
29+
usersSvc: usersSvc,
30+
31+
jwtSvc: jwtSvc,
32+
}
33+
}
34+
35+
func (h *Handler) Register(r fiber.Router) {
36+
admin := r.Group(
37+
"/admin/users",
38+
h.errorsHandler,
39+
jwtauth.New(h.jwtSvc, h.usersSvc),
40+
jwtauth.WithRole(users.RoleAdmin),
41+
)
42+
43+
// GET /admin/users - list all users with optional filters
44+
admin.Get("/", h.handleList)
45+
46+
// PATCH /admin/users/{id} - update user status/role
47+
admin.Patch("/:id", validation.DecorateWithBodyEx(h.Validator, h.handleUpdate))
48+
}
49+
50+
// handleList returns a paginated list of users with optional status filter.
51+
//
52+
// @Summary List all users
53+
// @Description Admin can list all users with optional status filter and pagination.
54+
// @Tags Admin
55+
// @Accept json
56+
// @Produce json
57+
// @Security BearerAuth
58+
// @Param status query users.Status false "Filter by status"
59+
// @Param role query users.Role false "Filter by role"
60+
// @Param limit query int false "Page limit" default(20)
61+
// @Param offset query int false "Page offset" default(0)
62+
// @Success 200 {object} UserListResponse "Users list"
63+
// @Failure 401 {object} fiberfx.ErrorResponse "Unauthorized"
64+
// @Failure 403 {object} fiberfx.ErrorResponse "Forbidden"
65+
// @Router /admin/users [get]
66+
func (h *Handler) handleList(c *fiber.Ctx) error {
67+
filter := defaultAdminUserFilter()
68+
69+
if err := h.QueryParserValidator(c, &filter); err != nil {
70+
return fmt.Errorf("failed to parse query: %w", err)
71+
}
72+
73+
// Get users from service
74+
usersList, err := h.usersSvc.List(c.Context(), filter.Status, filter.Role, filter.Limit, filter.Offset)
75+
if err != nil {
76+
return fmt.Errorf("failed to list users: %w", err)
77+
}
78+
79+
// Get total count for pagination
80+
total, err := h.usersSvc.Count(c.Context(), filter.Status, filter.Role)
81+
if err != nil {
82+
return fmt.Errorf("failed to count users: %w", err)
83+
}
84+
85+
// Convert to response DTOs
86+
items := make([]UserResponse, 0, len(usersList))
87+
for _, u := range usersList {
88+
items = append(items, toUserResponse(&u))
89+
}
90+
91+
return c.JSON(UserListResponse{
92+
Items: items,
93+
Total: int(total),
94+
})
95+
}
96+
97+
// handleUpdate updates user status and/or role by admin.
98+
//
99+
// @Summary Update user
100+
// @Description Admin can update user status (active/blocked/pending) and role (admin/user).
101+
// @Tags Admin
102+
// @Accept json
103+
// @Produce json
104+
// @Security BearerAuth
105+
// @Param id path int64 true "User ID"
106+
// @Param request body UpdateUserRequest true "Update data"
107+
// @Success 200 {object} UserResponse "Updated user"
108+
// @Failure 400 {object} fiberfx.ErrorResponse "Validation error"
109+
// @Failure 401 {object} fiberfx.ErrorResponse "Unauthorized"
110+
// @Failure 403 {object} fiberfx.ErrorResponse "Forbidden"
111+
// @Failure 404 {object} fiberfx.ErrorResponse "User not found"
112+
// @Router /admin/users/{id} [patch]
113+
func (h *Handler) handleUpdate(c *fiber.Ctx, req *UpdateUserRequest) error {
114+
// Parse user ID from path
115+
idStr := c.Params("id")
116+
userID, err := strconv.ParseInt(idStr, 10, 64)
117+
if err != nil {
118+
return fiber.NewError(fiber.StatusBadRequest, "invalid user id")
119+
}
120+
121+
// Validate at least one field is provided
122+
if req.Status == nil && req.Role == nil {
123+
return fiber.NewError(fiber.StatusBadRequest, "at least one of status or role must be provided")
124+
}
125+
126+
// Perform update
127+
if updErr := h.usersSvc.Update(
128+
c.Context(),
129+
userID,
130+
users.UserUpdate{Status: req.Status, Role: req.Role},
131+
); updErr != nil {
132+
if errors.Is(updErr, users.ErrNotFound) {
133+
return fiber.NewError(fiber.StatusNotFound, updErr.Error())
134+
}
135+
return fmt.Errorf("failed to update user: %w", updErr)
136+
}
137+
138+
// Fetch updated user
139+
updatedUser, err := h.usersSvc.GetByID(c.Context(), userID)
140+
if err != nil {
141+
return fmt.Errorf("failed to fetch updated user: %w", err)
142+
}
143+
144+
return c.JSON(toUserResponse(updatedUser))
145+
}
146+
147+
// errorsHandler converts service errors to HTTP errors.
148+
func (h *Handler) errorsHandler(c *fiber.Ctx) error {
149+
err := c.Next()
150+
if err == nil {
151+
return nil
152+
}
153+
154+
switch {
155+
case errors.Is(err, users.ErrEmailAlreadyUsed):
156+
return fiber.NewError(fiber.StatusConflict, err.Error())
157+
case errors.Is(err, users.ErrNotFound):
158+
return fiber.NewError(fiber.StatusNotFound, err.Error())
159+
case errors.Is(err, users.ErrInvalidCredential):
160+
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
161+
case errors.Is(err, users.ErrNotActive):
162+
return fiber.NewError(fiber.StatusForbidden, err.Error())
163+
164+
default:
165+
return err //nolint:wrapcheck // err is already wrapped
166+
}
167+
}

internal/server/auth/dto.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package auth
2+
3+
import (
4+
"time"
5+
6+
"github.com/bit-issues/backend/internal/users"
7+
)
8+
9+
// RegisterRequest represents user registration data.
10+
//
11+
// @Description User registration request with email and password.
12+
type RegisterRequest struct {
13+
Email string `json:"email" validate:"required,email"`
14+
Password string `json:"password" validate:"required,min=8,max=72"`
15+
}
16+
17+
// LoginRequest represents user login data.
18+
//
19+
// @Description User login credentials.
20+
type LoginRequest struct {
21+
Email string `json:"email" validate:"required,email"`
22+
Password string `json:"password" validate:"required"`
23+
}
24+
25+
// LoginResponse represents successful login response.
26+
//
27+
// @Description Successful login response containing JWT token and user info.
28+
type LoginResponse struct {
29+
AccessToken string `json:"access_token"`
30+
User UserResponseDTO `json:"user"`
31+
}
32+
33+
// UserResponseDTO represents user data in responses (without password).
34+
//
35+
// @Description User data returned in API responses (password excluded).
36+
type UserResponseDTO struct {
37+
ID int64 `json:"id"`
38+
Email string `json:"email"`
39+
Role users.Role `json:"role"`
40+
Status users.Status `json:"status"`
41+
CreatedAt time.Time `json:"created_at"`
42+
UpdatedAt time.Time `json:"updated_at"`
43+
}
44+
45+
// ChangePasswordRequest represents password change request.
46+
//
47+
// @Description Request to change user's password (requires old password for verification).
48+
type ChangePasswordRequest struct {
49+
OldPassword string `json:"old_password" validate:"required"`
50+
NewPassword string `json:"new_password" validate:"required,min=8,max=72"`
51+
}
52+
53+
// toUserResponseDTO converts domain User to response DTO.
54+
func toUserResponseDTO(u *users.User) UserResponseDTO {
55+
return UserResponseDTO{
56+
ID: u.ID,
57+
Email: u.Email,
58+
Role: u.Role,
59+
Status: u.Status,
60+
CreatedAt: u.CreatedAt,
61+
UpdatedAt: u.UpdatedAt,
62+
}
63+
}

0 commit comments

Comments
 (0)