Add paginated users listing

This commit is contained in:
Tiago Yamamoto 2025-12-22 16:43:54 -03:00
parent 749a83efa4
commit 9c17a7a15a
9 changed files with 189 additions and 77 deletions

View file

@ -717,6 +717,20 @@ const docTemplate = `{
"consumes": [ "consumes": [
"application/json" "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": [ "produces": [
"application/json" "application/json"
], ],
@ -728,10 +742,7 @@ const docTemplate = `{
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"type": "array", "$ref": "#/definitions/dto.PaginatedResponse"
"items": {
"$ref": "#/definitions/dto.UserResponse"
}
} }
}, },
"403": { "403": {

View file

@ -710,6 +710,20 @@
"consumes": [ "consumes": [
"application/json" "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": [ "produces": [
"application/json" "application/json"
], ],
@ -721,10 +735,7 @@
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"type": "array", "$ref": "#/definitions/dto.PaginatedResponse"
"items": {
"$ref": "#/definitions/dto.UserResponse"
}
} }
}, },
"403": { "403": {
@ -1423,4 +1434,4 @@
} }
} }
} }
} }

View file

@ -857,15 +857,22 @@ paths:
consumes: consumes:
- application/json - application/json
description: Returns a list of users belonging to the authenticated tenant. 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: produces:
- application/json - application/json
responses: responses:
"200": "200":
description: OK description: OK
schema: schema:
items: $ref: '#/definitions/dto.PaginatedResponse'
$ref: '#/definitions/dto.UserResponse'
type: array
"403": "403":
description: Forbidden description: Forbidden
schema: schema:

View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net" "net"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/rede5/gohorsejobs/backend/internal/api/middleware" "github.com/rede5/gohorsejobs/backend/internal/api/middleware"
@ -173,7 +174,9 @@ func (h *CoreHandlers) CreateUser(w http.ResponseWriter, r *http.Request) {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security BearerAuth // @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 403 {string} string "Forbidden"
// @Failure 500 {string} string "Internal Server Error" // @Failure 500 {string} string "Internal Server Error"
// @Router /api/v1/users [get] // @Router /api/v1/users [get]
@ -185,7 +188,10 @@ func (h *CoreHandlers) ListUsers(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return

View file

@ -19,7 +19,7 @@ type UserRepository interface {
FindByID(ctx context.Context, id string) (*entity.User, error) FindByID(ctx context.Context, id string) (*entity.User, error)
FindByEmail(ctx context.Context, email string) (*entity.User, error) FindByEmail(ctx context.Context, email string) (*entity.User, error)
// FindAllByTenant returns users strictly scoped to a tenant // 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) Update(ctx context.Context, user *entity.User) (*entity.User, error)
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
} }

View file

@ -17,8 +17,22 @@ func NewListUsersUseCase(uRepo ports.UserRepository) *ListUsersUseCase {
} }
} }
func (uc *ListUsersUseCase) Execute(ctx context.Context, tenantID string) ([]dto.UserResponse, error) { func (uc *ListUsersUseCase) Execute(ctx context.Context, tenantID string, page, limit int) (*dto.PaginatedResponse, error) {
users, err := uc.userRepo.FindAllByTenant(ctx, tenantID) 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 { if err != nil {
return nil, err 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
} }

View file

@ -96,11 +96,21 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User,
return u, nil return u, nil
} }
func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string) ([]*entity.User, error) { func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, limit, offset int) ([]*entity.User, int, error) {
query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at FROM core_users WHERE tenant_id = $1` var total int
rows, err := r.db.QueryContext(ctx, query, tenantID) 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 { if err != nil {
return nil, err return nil, 0, err
} }
defer rows.Close() defer rows.Close()
@ -108,13 +118,13 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string) (
for rows.Next() { for rows.Next() {
u := &entity.User{} 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 { 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 // Populate roles N+1? Ideally join, but for now simple
u.Roles, _ = r.getRoles(ctx, u.ID) u.Roles, _ = r.getRoles(ctx, u.ID)
users = append(users, u) users = append(users, u)
} }
return users, nil return users, total, nil
} }
func (r *UserRepository) Update(ctx context.Context, user *entity.User) (*entity.User, error) { func (r *UserRepository) Update(ctx context.Context, user *entity.User) (*entity.User, error) {

View file

@ -28,6 +28,8 @@ export default function AdminUsersPage() {
const [users, setUsers] = useState<ApiUser[]>([]) const [users, setUsers] = useState<ApiUser[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState("") const [searchTerm, setSearchTerm] = useState("")
const [page, setPage] = useState(1)
const [totalUsers, setTotalUsers] = useState(0)
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@ -46,11 +48,16 @@ export default function AdminUsersPage() {
loadUsers() loadUsers()
}, [router]) }, [router])
const loadUsers = async () => { const limit = 10
const totalPages = Math.max(1, Math.ceil(totalUsers / limit))
const loadUsers = async (targetPage = page) => {
try { try {
setLoading(true) setLoading(true)
const data = await usersApi.list() const data = await usersApi.list({ page: targetPage, limit })
setUsers(data || []) setUsers(data?.data || [])
setTotalUsers(data?.pagination?.total || 0)
setPage(data?.pagination?.page || targetPage)
} catch (error) { } catch (error) {
console.error("Error loading users:", error) console.error("Error loading users:", error)
toast.error("Failed to load users") toast.error("Failed to load users")
@ -66,7 +73,8 @@ export default function AdminUsersPage() {
toast.success("User created successfully!") toast.success("User created successfully!")
setIsDialogOpen(false) setIsDialogOpen(false)
setFormData({ name: "", email: "", password: "", role: "jobSeeker" }) setFormData({ name: "", email: "", password: "", role: "jobSeeker" })
loadUsers() setPage(1)
loadUsers(1)
} catch (error) { } catch (error) {
console.error("Error creating user:", error) console.error("Error creating user:", error)
toast.error("Failed to create user") toast.error("Failed to create user")
@ -80,7 +88,9 @@ export default function AdminUsersPage() {
try { try {
await usersApi.delete(id) await usersApi.delete(id)
toast.success("User deleted!") toast.success("User deleted!")
loadUsers() const nextPage = page > 1 && users.length === 1 ? page - 1 : page
setPage(nextPage)
loadUsers(nextPage)
} catch (error) { } catch (error) {
console.error("Error deleting user:", error) console.error("Error deleting user:", error)
toast.error("Failed to delete user") toast.error("Failed to delete user")
@ -201,12 +211,12 @@ export default function AdminUsersPage() {
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardDescription>Total users</CardDescription> <CardDescription>Total users</CardDescription>
<CardTitle className="text-3xl">{users.length}</CardTitle> <CardTitle className="text-3xl">{totalUsers}</CardTitle>
</CardHeader> </CardHeader>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardDescription>Admins</CardDescription> <CardDescription>Admins (page)</CardDescription>
<CardTitle className="text-3xl"> <CardTitle className="text-3xl">
{users.filter((u) => u.role === "superadmin" || u.role === "companyAdmin").length} {users.filter((u) => u.role === "superadmin" || u.role === "companyAdmin").length}
</CardTitle> </CardTitle>
@ -214,13 +224,13 @@ export default function AdminUsersPage() {
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardDescription>Recruiters</CardDescription> <CardDescription>Recruiters (page)</CardDescription>
<CardTitle className="text-3xl">{users.filter((u) => u.role === "recruiter").length}</CardTitle> <CardTitle className="text-3xl">{users.filter((u) => u.role === "recruiter").length}</CardTitle>
</CardHeader> </CardHeader>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardDescription>Candidates</CardDescription> <CardDescription>Candidates (page)</CardDescription>
<CardTitle className="text-3xl">{users.filter((u) => u.role === "jobSeeker").length}</CardTitle> <CardTitle className="text-3xl">{users.filter((u) => u.role === "jobSeeker").length}</CardTitle>
</CardHeader> </CardHeader>
</Card> </Card>
@ -247,53 +257,83 @@ export default function AdminUsersPage() {
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div> </div>
) : ( ) : (
<Table> <div className="space-y-4">
<TableHeader> <Table>
<TableRow> <TableHeader>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8"> <TableHead>Name</TableHead>
No users found <TableHead>Email</TableHead>
</TableCell> <TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
) : ( </TableHeader>
filteredUsers.map((user) => ( <TableBody>
<TableRow key={user.id}> {filteredUsers.length === 0 ? (
<TableCell className="font-medium">{user.name}</TableCell> <TableRow>
<TableCell>{user.email}</TableCell> <TableCell colSpan={6} className="text-center text-muted-foreground py-8">
<TableCell>{getRoleBadge(user.role)}</TableCell> No users found
<TableCell>
<Badge variant={user.status === "active" ? "default" : "secondary"}>
{user.status === "active" ? "Active" : user.status}
</Badge>
</TableCell>
<TableCell>
{user.created_at ? new Date(user.created_at).toLocaleDateString("en-US") : "-"}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(user.id)}
disabled={user.role === "superadmin"}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ) : (
)} filteredUsers.map((user) => (
</TableBody> <TableRow key={user.id}>
</Table> <TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{getRoleBadge(user.role)}</TableCell>
<TableCell>
<Badge variant={user.status === "active" ? "default" : "secondary"}>
{user.status === "active" ? "Active" : user.status}
</Badge>
</TableCell>
<TableCell>
{user.created_at ? new Date(user.created_at).toLocaleDateString("en-US") : "-"}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(user.id)}
disabled={user.role === "superadmin"}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span>
{totalUsers === 0
? "No users to display"
: `Showing ${(page - 1) * limit + 1}-${Math.min(page * limit, totalUsers)} of ${totalUsers}`}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => loadUsers(page - 1)}
disabled={page <= 1 || loading}
>
Previous
</Button>
<span>
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => loadUsers(page + 1)}
disabled={page >= totalPages || loading}
>
Next
</Button>
</div>
</div>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View file

@ -71,7 +71,13 @@ async function apiRequest<T>(
// Users API // Users API
export const usersApi = { export const usersApi = {
list: () => apiRequest<ApiUser[]>("/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<PaginatedResponse<ApiUser>>(`/api/v1/users${queryStr ? `?${queryStr}` : ""}`);
},
create: (data: { name: string; email: string; password: string; role: string }) => create: (data: { name: string; email: string; password: string; role: string }) =>
apiRequest<ApiUser>("/api/v1/users", { apiRequest<ApiUser>("/api/v1/users", {