Skip to content

Commit dca989f

Browse files
committed
feat(backend): add pagination and search to user endpoints
- Add pagination support to GET /users endpoint (limit, offset) - Create new GET /users/search endpoint with filters (username, email, auth_type, role_id) - Remove unused GET /users/role/:roleId endpoint - Update response format to include pagination metadata - Add pagination query schemas and validation helper - Update unit tests for new paginated response structure BREAKING CHANGE: GET /users response format changed from { success, data: [...] } to { success, data: { users: [...], pagination: {...} } }
1 parent 96e864a commit dca989f

File tree

8 files changed

+1222
-615
lines changed

8 files changed

+1222
-615
lines changed

services/backend/api-spec.json

Lines changed: 481 additions & 284 deletions
Large diffs are not rendered by default.

services/backend/api-spec.yaml

Lines changed: 351 additions & 207 deletions
Large diffs are not rendered by default.

services/backend/src/routes/users/getUsersByRole.ts

Lines changed: 0 additions & 83 deletions
This file was deleted.

services/backend/src/routes/users/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import type { FastifyInstance } from 'fastify';
22
import listUsersRoute from './listUsers';
3+
import searchUsersRoute from './search';
34
import getUserByIdRoute from './getUserById';
45
import updateUserRoute from './updateUser';
56
import deleteUserRoute from './deleteUser';
67
import assignRoleRoute from './assignRole';
78
import getUserStatsRoute from './getUserStats';
8-
import getUsersByRoleRoute from './getUsersByRole';
99
import getCurrentUserRoute from './getCurrentUser';
1010
import getCurrentUserTeamsRoute from './getCurrentUserTeams';
1111
import deleteMyAccountRoute from './deleteMyAccount';
@@ -19,12 +19,12 @@ import metricsRoutes from './me/metrics';
1919
export default async function usersRoute(server: FastifyInstance) {
2020
// Register individual user route handlers
2121
await server.register(listUsersRoute);
22+
await server.register(searchUsersRoute);
2223
await server.register(getUserByIdRoute);
2324
await server.register(updateUserRoute);
2425
await server.register(deleteUserRoute);
2526
await server.register(assignRoleRoute);
2627
await server.register(getUserStatsRoute);
27-
await server.register(getUsersByRoleRoute);
2828
await server.register(getCurrentUserRoute);
2929
await server.register(getCurrentUserTeamsRoute);
3030
await server.register(deleteMyAccountRoute);

services/backend/src/routes/users/listUsers.ts

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,40 @@
11
import type { FastifyInstance } from 'fastify';
22
import { UserService } from '../../services/userService';
33
import { requirePermission } from '../../middleware/roleMiddleware';
4-
import {
5-
ERROR_RESPONSE_SCHEMA,
6-
USERS_LIST_RESPONSE_SCHEMA,
4+
import {
5+
ERROR_RESPONSE_SCHEMA,
6+
PAGINATION_QUERY_SCHEMA,
7+
USERS_LIST_PAGINATED_RESPONSE_SCHEMA,
78
type ErrorResponse,
8-
type UsersListResponse,
9-
type User
9+
type UsersListPaginatedResponse,
10+
type User,
11+
type PaginationQuery,
12+
validatePaginationParams
1013
} from './schemas';
1114

1215
export default async function listUsersRoute(server: FastifyInstance) {
1316
const userService = new UserService();
1417

15-
// GET /users - List all users (admin only)
18+
// GET /users - List all users (admin only) with pagination
1619
server.get('/users', {
1720
preValidation: requirePermission('users.list'),
1821
schema: {
1922
tags: ['Users'],
2023
summary: 'List all users',
21-
description: 'Retrieves a list of all users in the system. Requires admin permissions.',
24+
description: 'Retrieves a paginated list of all users in the system. Requires admin permissions. Supports pagination with limit (1-100, default: 20) and offset (default: 0) parameters.',
2225
security: [{ cookieAuth: [] }],
23-
26+
27+
querystring: PAGINATION_QUERY_SCHEMA,
28+
2429
response: {
2530
200: {
26-
...USERS_LIST_RESPONSE_SCHEMA,
31+
...USERS_LIST_PAGINATED_RESPONSE_SCHEMA,
2732
description: 'Successfully retrieved users list'
2833
},
34+
400: {
35+
...ERROR_RESPONSE_SCHEMA,
36+
description: 'Bad Request - Invalid pagination parameters'
37+
},
2938
401: {
3039
...ERROR_RESPONSE_SCHEMA,
3140
description: 'Unauthorized - Authentication required'
@@ -42,8 +51,12 @@ export default async function listUsersRoute(server: FastifyInstance) {
4251
},
4352
}, async (request, reply) => {
4453
try {
54+
// Parse and validate pagination parameters
55+
const query = request.query as PaginationQuery;
56+
const { limit, offset } = validatePaginationParams(query);
57+
4558
const users = await userService.getAllUsers();
46-
59+
4760
// Convert users to proper response format following getCurrentUser pattern
4861
const serializedUsers: User[] = users.map(user => ({
4962
id: String(user.id),
@@ -60,14 +73,44 @@ export default async function listUsersRoute(server: FastifyInstance) {
6073
permissions: user.role.permissions
6174
} : undefined
6275
}));
63-
64-
const successResponse: UsersListResponse = {
76+
77+
// Apply pagination
78+
const total = serializedUsers.length;
79+
const paginatedUsers = serializedUsers.slice(offset, offset + limit);
80+
81+
server.log.info({
82+
operation: 'list_users',
83+
totalResults: total,
84+
returnedResults: paginatedUsers.length,
85+
pagination: { limit, offset }
86+
}, 'Users list completed');
87+
88+
const successResponse: UsersListPaginatedResponse = {
6589
success: true,
66-
data: serializedUsers
90+
data: {
91+
users: paginatedUsers,
92+
pagination: {
93+
total,
94+
limit,
95+
offset,
96+
has_more: offset + limit < total
97+
}
98+
}
6799
};
68100
const jsonString = JSON.stringify(successResponse);
69101
return reply.status(200).type('application/json').send(jsonString);
70102
} catch (error) {
103+
// Check if it's a validation error
104+
if (error instanceof Error && error.message.includes('must be')) {
105+
server.log.warn({ error: error.message }, 'Invalid pagination parameters');
106+
const errorResponse: ErrorResponse = {
107+
success: false,
108+
error: error.message
109+
};
110+
const jsonString = JSON.stringify(errorResponse);
111+
return reply.status(400).type('application/json').send(jsonString);
112+
}
113+
71114
server.log.error(error, 'Error fetching users');
72115
const errorResponse: ErrorResponse = {
73116
success: false,

services/backend/src/routes/users/schemas.ts

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,11 +222,88 @@ export const ASSIGN_ROLE_REQUEST_SCHEMA = {
222222
additionalProperties: false
223223
} as const;
224224

225+
// ===== PAGINATION SCHEMAS =====
226+
export const PAGINATION_QUERY_SCHEMA = {
227+
type: 'object',
228+
properties: {
229+
limit: {
230+
type: 'string',
231+
pattern: '^\\d+$',
232+
description: 'Maximum number of items to return (1-100, default: 20)'
233+
},
234+
offset: {
235+
type: 'string',
236+
pattern: '^\\d+$',
237+
description: 'Number of items to skip (≥0, default: 0)'
238+
}
239+
},
240+
additionalProperties: false
241+
} as const;
242+
243+
const PAGINATION_SCHEMA = {
244+
type: 'object',
245+
properties: {
246+
total: {
247+
type: 'number',
248+
description: 'Total number of users'
249+
},
250+
limit: {
251+
type: 'number',
252+
description: 'Number of users per page'
253+
},
254+
offset: {
255+
type: 'number',
256+
description: 'Number of users skipped'
257+
},
258+
has_more: {
259+
type: 'boolean',
260+
description: 'Whether there are more users beyond this page'
261+
}
262+
},
263+
required: ['total', 'limit', 'offset', 'has_more']
264+
} as const;
265+
266+
export const SEARCH_USERS_QUERY_SCHEMA = {
267+
type: 'object',
268+
properties: {
269+
// Search filters (all optional)
270+
username: {
271+
type: 'string',
272+
description: 'Filter by username (partial match, case-insensitive)'
273+
},
274+
email: {
275+
type: 'string',
276+
description: 'Filter by email (partial match, case-insensitive)'
277+
},
278+
auth_type: {
279+
type: 'string',
280+
enum: ['email', 'github'],
281+
description: 'Filter by authentication type'
282+
},
283+
role_id: {
284+
type: 'string',
285+
description: 'Filter by role ID (exact match)'
286+
},
287+
// Pagination
288+
limit: {
289+
type: 'string',
290+
pattern: '^\\d+$',
291+
description: 'Maximum number of items to return (1-100, default: 20)'
292+
},
293+
offset: {
294+
type: 'string',
295+
pattern: '^\\d+$',
296+
description: 'Number of items to skip (≥0, default: 0)'
297+
}
298+
},
299+
additionalProperties: false
300+
} as const;
301+
225302
// ===== RESPONSE SCHEMAS =====
226303
export const USERS_LIST_RESPONSE_SCHEMA = {
227304
type: 'object',
228305
properties: {
229-
success: {
306+
success: {
230307
type: 'boolean',
231308
description: 'Indicates if the operation was successful'
232309
},
@@ -239,6 +316,29 @@ export const USERS_LIST_RESPONSE_SCHEMA = {
239316
required: ['success', 'data']
240317
} as const;
241318

319+
export const USERS_LIST_PAGINATED_RESPONSE_SCHEMA = {
320+
type: 'object',
321+
properties: {
322+
success: {
323+
type: 'boolean',
324+
description: 'Indicates if the operation was successful'
325+
},
326+
data: {
327+
type: 'object',
328+
properties: {
329+
users: {
330+
type: 'array',
331+
items: USER_SCHEMA,
332+
description: 'Array of users for current page'
333+
},
334+
pagination: PAGINATION_SCHEMA
335+
},
336+
required: ['users', 'pagination']
337+
}
338+
},
339+
required: ['success', 'data']
340+
} as const;
341+
242342
export const USER_TEAMS_RESPONSE_SCHEMA = {
243343
type: 'object',
244344
properties: {
@@ -328,12 +428,55 @@ export interface AssignRoleRequest {
328428
role_id: string;
329429
}
330430

431+
export interface PaginationQuery {
432+
limit?: string;
433+
offset?: string;
434+
}
435+
436+
export interface SearchUsersQuery extends PaginationQuery {
437+
username?: string;
438+
email?: string;
439+
auth_type?: 'email' | 'github';
440+
role_id?: string;
441+
}
442+
443+
export interface PaginationMetadata {
444+
total: number;
445+
limit: number;
446+
offset: number;
447+
has_more: boolean;
448+
}
449+
331450
export interface UsersListResponse {
332451
success: boolean;
333452
data: User[];
334453
}
335454

455+
export interface UsersListPaginatedResponse {
456+
success: boolean;
457+
data: {
458+
users: User[];
459+
pagination: PaginationMetadata;
460+
};
461+
}
462+
336463
export interface UserTeamsResponse {
337464
success: boolean;
338465
teams: TeamItem[];
339466
}
467+
468+
// Validation helper function
469+
export function validatePaginationParams(query: PaginationQuery): { limit: number; offset: number } {
470+
const limit = query.limit ? parseInt(query.limit, 10) : 20;
471+
const offset = query.offset ? parseInt(query.offset, 10) : 0;
472+
473+
if (isNaN(limit) || limit < 1 || limit > 100) {
474+
throw new Error('Limit must be between 1 and 100');
475+
}
476+
477+
if (isNaN(offset) || offset < 0) {
478+
throw new Error('Offset must be non-negative');
479+
}
480+
481+
return { limit, offset };
482+
}

0 commit comments

Comments
 (0)