From 9c17a7a15a46ab247d16a484fb8c5d4adaa31d57 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Mon, 22 Dec 2025 16:43:54 -0300 Subject: [PATCH] Add paginated users listing --- backend/docs/docs.go | 19 ++- backend/docs/swagger.json | 21 ++- backend/docs/swagger.yaml | 13 +- .../internal/api/handlers/core_handlers.go | 10 +- backend/internal/core/ports/repositories.go | 2 +- .../internal/core/usecases/user/list_users.go | 27 +++- .../persistence/postgres/user_repository.go | 22 ++- frontend/src/app/dashboard/users/page.tsx | 144 +++++++++++------- frontend/src/lib/api.ts | 8 +- 9 files changed, 189 insertions(+), 77 deletions(-) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index f0aca17..7b158ca 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -717,6 +717,20 @@ const docTemplate = `{ "consumes": [ "application/json" ], + "parameters": [ + { + "type": "integer", + "description": "Page number (default: 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page (default: 10, max: 100)", + "name": "limit", + "in": "query" + } + ], "produces": [ "application/json" ], @@ -728,10 +742,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/dto.UserResponse" - } + "$ref": "#/definitions/dto.PaginatedResponse" } }, "403": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 7eff63a..8cd9a8d 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -710,6 +710,20 @@ "consumes": [ "application/json" ], + "parameters": [ + { + "type": "integer", + "description": "Page number (default: 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page (default: 10, max: 100)", + "name": "limit", + "in": "query" + } + ], "produces": [ "application/json" ], @@ -721,10 +735,7 @@ "200": { "description": "OK", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/dto.UserResponse" - } + "$ref": "#/definitions/dto.PaginatedResponse" } }, "403": { @@ -1423,4 +1434,4 @@ } } } -} \ No newline at end of file +} diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 3eeb934..b8e06a7 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -857,15 +857,22 @@ paths: consumes: - application/json description: Returns a list of users belonging to the authenticated tenant. + parameters: + - description: Page number (default: 1) + in: query + name: page + type: integer + - description: Items per page (default: 10, max: 100) + in: query + name: limit + type: integer produces: - application/json responses: "200": description: OK schema: - items: - $ref: '#/definitions/dto.UserResponse' - type: array + $ref: '#/definitions/dto.PaginatedResponse' "403": description: Forbidden schema: diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index c713210..7086a92 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net" "net/http" + "strconv" "strings" "github.com/rede5/gohorsejobs/backend/internal/api/middleware" @@ -173,7 +174,9 @@ func (h *CoreHandlers) CreateUser(w http.ResponseWriter, r *http.Request) { // @Accept json // @Produce json // @Security BearerAuth -// @Success 200 {array} dto.UserResponse +// @Param page query int false "Page number (default: 1)" +// @Param limit query int false "Items per page (default: 10, max: 100)" +// @Success 200 {object} dto.PaginatedResponse // @Failure 403 {string} string "Forbidden" // @Failure 500 {string} string "Internal Server Error" // @Router /api/v1/users [get] @@ -185,7 +188,10 @@ func (h *CoreHandlers) ListUsers(w http.ResponseWriter, r *http.Request) { return } - users, err := h.listUsersUC.Execute(ctx, tenantID) + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + + users, err := h.listUsersUC.Execute(ctx, tenantID, page, limit) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/backend/internal/core/ports/repositories.go b/backend/internal/core/ports/repositories.go index ba67923..8e2026e 100644 --- a/backend/internal/core/ports/repositories.go +++ b/backend/internal/core/ports/repositories.go @@ -19,7 +19,7 @@ type UserRepository interface { FindByID(ctx context.Context, id string) (*entity.User, error) FindByEmail(ctx context.Context, email string) (*entity.User, error) // FindAllByTenant returns users strictly scoped to a tenant - FindAllByTenant(ctx context.Context, tenantID string) ([]*entity.User, error) + FindAllByTenant(ctx context.Context, tenantID string, limit, offset int) ([]*entity.User, int, error) Update(ctx context.Context, user *entity.User) (*entity.User, error) Delete(ctx context.Context, id string) error } diff --git a/backend/internal/core/usecases/user/list_users.go b/backend/internal/core/usecases/user/list_users.go index dd4cff2..df2a14d 100644 --- a/backend/internal/core/usecases/user/list_users.go +++ b/backend/internal/core/usecases/user/list_users.go @@ -17,8 +17,22 @@ func NewListUsersUseCase(uRepo ports.UserRepository) *ListUsersUseCase { } } -func (uc *ListUsersUseCase) Execute(ctx context.Context, tenantID string) ([]dto.UserResponse, error) { - users, err := uc.userRepo.FindAllByTenant(ctx, tenantID) +func (uc *ListUsersUseCase) Execute(ctx context.Context, tenantID string, page, limit int) (*dto.PaginatedResponse, error) { + if page <= 0 { + page = 1 + } + if limit <= 0 { + limit = 10 + } + if limit > 100 { + limit = 100 + } + offset := (page - 1) * limit + if offset < 0 { + offset = 0 + } + + users, total, err := uc.userRepo.FindAllByTenant(ctx, tenantID, limit, offset) if err != nil { return nil, err } @@ -40,5 +54,12 @@ func (uc *ListUsersUseCase) Execute(ctx context.Context, tenantID string) ([]dto }) } - return response, nil + return &dto.PaginatedResponse{ + Data: response, + Pagination: dto.Pagination{ + Page: page, + Limit: limit, + Total: total, + }, + }, nil } diff --git a/backend/internal/infrastructure/persistence/postgres/user_repository.go b/backend/internal/infrastructure/persistence/postgres/user_repository.go index e35e17c..3076d5a 100644 --- a/backend/internal/infrastructure/persistence/postgres/user_repository.go +++ b/backend/internal/infrastructure/persistence/postgres/user_repository.go @@ -96,11 +96,21 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, return u, nil } -func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string) ([]*entity.User, error) { - query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at FROM core_users WHERE tenant_id = $1` - rows, err := r.db.QueryContext(ctx, query, tenantID) +func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, limit, offset int) ([]*entity.User, int, error) { + var total int + countQuery := `SELECT COUNT(*) FROM core_users WHERE tenant_id = $1` + if err := r.db.QueryRowContext(ctx, countQuery, tenantID).Scan(&total); err != nil { + return nil, 0, err + } + + query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at + FROM core_users + WHERE tenant_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3` + rows, err := r.db.QueryContext(ctx, query, tenantID, limit, offset) if err != nil { - return nil, err + return nil, 0, err } defer rows.Close() @@ -108,13 +118,13 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string) ( for rows.Next() { u := &entity.User{} if err := rows.Scan(&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil { - return nil, err + return nil, 0, err } // Populate roles N+1? Ideally join, but for now simple u.Roles, _ = r.getRoles(ctx, u.ID) users = append(users, u) } - return users, nil + return users, total, nil } func (r *UserRepository) Update(ctx context.Context, user *entity.User) (*entity.User, error) { diff --git a/frontend/src/app/dashboard/users/page.tsx b/frontend/src/app/dashboard/users/page.tsx index 282d0f8..9ffe484 100644 --- a/frontend/src/app/dashboard/users/page.tsx +++ b/frontend/src/app/dashboard/users/page.tsx @@ -28,6 +28,8 @@ export default function AdminUsersPage() { const [users, setUsers] = useState([]) const [loading, setLoading] = useState(true) const [searchTerm, setSearchTerm] = useState("") + const [page, setPage] = useState(1) + const [totalUsers, setTotalUsers] = useState(0) const [isDialogOpen, setIsDialogOpen] = useState(false) const [creating, setCreating] = useState(false) const [formData, setFormData] = useState({ @@ -46,11 +48,16 @@ export default function AdminUsersPage() { loadUsers() }, [router]) - const loadUsers = async () => { + const limit = 10 + const totalPages = Math.max(1, Math.ceil(totalUsers / limit)) + + const loadUsers = async (targetPage = page) => { try { setLoading(true) - const data = await usersApi.list() - setUsers(data || []) + const data = await usersApi.list({ page: targetPage, limit }) + setUsers(data?.data || []) + setTotalUsers(data?.pagination?.total || 0) + setPage(data?.pagination?.page || targetPage) } catch (error) { console.error("Error loading users:", error) toast.error("Failed to load users") @@ -66,7 +73,8 @@ export default function AdminUsersPage() { toast.success("User created successfully!") setIsDialogOpen(false) setFormData({ name: "", email: "", password: "", role: "jobSeeker" }) - loadUsers() + setPage(1) + loadUsers(1) } catch (error) { console.error("Error creating user:", error) toast.error("Failed to create user") @@ -80,7 +88,9 @@ export default function AdminUsersPage() { try { await usersApi.delete(id) toast.success("User deleted!") - loadUsers() + const nextPage = page > 1 && users.length === 1 ? page - 1 : page + setPage(nextPage) + loadUsers(nextPage) } catch (error) { console.error("Error deleting user:", error) toast.error("Failed to delete user") @@ -201,12 +211,12 @@ export default function AdminUsersPage() { Total users - {users.length} + {totalUsers} - Admins + Admins (page) {users.filter((u) => u.role === "superadmin" || u.role === "companyAdmin").length} @@ -214,13 +224,13 @@ export default function AdminUsersPage() { - Recruiters + Recruiters (page) {users.filter((u) => u.role === "recruiter").length} - Candidates + Candidates (page) {users.filter((u) => u.role === "jobSeeker").length} @@ -247,53 +257,83 @@ export default function AdminUsersPage() { ) : ( - - - - Name - Email - Role - Status - Created - Actions - - - - {filteredUsers.length === 0 ? ( +
+
+ - - No users found - + Name + Email + Role + Status + Created + Actions - ) : ( - filteredUsers.map((user) => ( - - {user.name} - {user.email} - {getRoleBadge(user.role)} - - - {user.status === "active" ? "Active" : user.status} - - - - {user.created_at ? new Date(user.created_at).toLocaleDateString("en-US") : "-"} - - - + + + {filteredUsers.length === 0 ? ( + + + No users found - )) - )} - -
+ ) : ( + filteredUsers.map((user) => ( + + {user.name} + {user.email} + {getRoleBadge(user.role)} + + + {user.status === "active" ? "Active" : user.status} + + + + {user.created_at ? new Date(user.created_at).toLocaleDateString("en-US") : "-"} + + + + + + )) + )} + + +
+ + {totalUsers === 0 + ? "No users to display" + : `Showing ${(page - 1) * limit + 1}-${Math.min(page * limit, totalUsers)} of ${totalUsers}`} + +
+ + + Page {page} of {totalPages} + + +
+
+ )} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7602fe7..4dde1ad 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -71,7 +71,13 @@ async function apiRequest( // Users API export const usersApi = { - list: () => apiRequest("/api/v1/users"), + list: (params?: { page?: number; limit?: number }) => { + const query = new URLSearchParams(); + if (params?.page) query.set("page", String(params.page)); + if (params?.limit) query.set("limit", String(params.limit)); + const queryStr = query.toString(); + return apiRequest>(`/api/v1/users${queryStr ? `?${queryStr}` : ""}`); + }, create: (data: { name: string; email: string; password: string; role: string }) => apiRequest("/api/v1/users", {