Skip to content

Commit 521ccc1

Browse files
authored
Merge pull request #299 from devmasalati/feature/analytics-pagination
feat: add pagination support to analytics endpoints
2 parents 94d4255 + 51784ec commit 521ccc1

3 files changed

Lines changed: 164 additions & 2 deletions

File tree

backend/src/__tests__/analyticsService.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,56 @@ describe('AnalyticsService', () => {
2222
expect(dashboard.statusDistribution).toBeDefined();
2323
expect(Array.isArray(dashboard.insights)).toBe(true);
2424
});
25+
26+
describe('getPaginatedMonthlyGrowth', () => {
27+
it('returns pagination metadata', async () => {
28+
const service = new AnalyticsService();
29+
const result = await service.getPaginatedMonthlyGrowth({ page: 1, limit: 5 });
30+
31+
expect(result).toHaveProperty('data');
32+
expect(result).toHaveProperty('pagination');
33+
expect(Array.isArray(result.data)).toBe(true);
34+
35+
const { pagination } = result;
36+
expect(pagination.page).toBe(1);
37+
expect(pagination.limit).toBe(5);
38+
expect(typeof pagination.total).toBe('number');
39+
expect(typeof pagination.totalPages).toBe('number');
40+
expect(typeof pagination.hasNextPage).toBe('boolean');
41+
expect(pagination.hasPreviousPage).toBe(false);
42+
});
43+
44+
it('returns at most `limit` items per page', async () => {
45+
const service = new AnalyticsService();
46+
const result = await service.getPaginatedMonthlyGrowth({ page: 1, limit: 3 });
47+
48+
expect(result.data.length).toBeLessThanOrEqual(3);
49+
});
50+
51+
it('returns correct page 2 slice', async () => {
52+
const service = new AnalyticsService();
53+
const page1 = await service.getPaginatedMonthlyGrowth({ page: 1, limit: 2 });
54+
const page2 = await service.getPaginatedMonthlyGrowth({ page: 2, limit: 2 });
55+
56+
expect(page2.pagination.page).toBe(2);
57+
// page 2 items must differ from page 1 items (unless total <= 2)
58+
if (page1.pagination.total > 2) {
59+
expect(page2.data[0]).not.toEqual(page1.data[0]);
60+
expect(page2.pagination.hasPreviousPage).toBe(true);
61+
}
62+
});
63+
64+
it('hasNextPage is false on last page', async () => {
65+
const service = new AnalyticsService();
66+
// Fetch all data in one large page
67+
const all = await service.getPaginatedMonthlyGrowth({ page: 1, limit: 1000 });
68+
expect(all.pagination.hasNextPage).toBe(false);
69+
});
70+
71+
it('totalPages is at least 1 even when data is empty', async () => {
72+
const service = new AnalyticsService();
73+
const result = await service.getPaginatedMonthlyGrowth({ page: 1, limit: 12 });
74+
expect(result.pagination.totalPages).toBeGreaterThanOrEqual(1);
75+
});
76+
});
2577
});

backend/src/routes/analytics.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
sanitizeMeterId,
77
sanitizeString,
88
sanitizePositiveNumber,
9+
sanitizeInteger,
910
validationError,
1011
type ValidationError,
1112
} from "../utils/sanitize";
@@ -50,16 +51,46 @@ router.get("/user/:userId", async (req, res) => {
5051
/**
5152
* GET /api/analytics/system
5253
* Get system-wide analytics (admin only)
54+
* Query params: page (default 1), limit (default 12)
5355
*/
5456
router.get("/system", async (req, res) => {
5557
try {
58+
const errors: ValidationError[] = [];
59+
60+
const rawPage = sanitizeInteger(req.query.page ?? '1', 1, 10_000);
61+
const rawLimit = sanitizeInteger(req.query.limit ?? '12', 1, 100);
62+
63+
if (req.query.page !== undefined && Number.isNaN(rawPage)) errors.push(validationError('page', 'page must be a positive integer (1–10000)'));
64+
if (req.query.limit !== undefined && Number.isNaN(rawLimit)) errors.push(validationError('limit', 'limit must be an integer between 1 and 100'));
65+
if (errors.length > 0) return res.status(400).json({ success: false, errors });
66+
67+
const page = Number.isNaN(rawPage) ? 1 : rawPage;
68+
const limit = Number.isNaN(rawLimit) ? 12 : rawLimit;
69+
5670
// TODO: Add admin authentication check
5771
const analytics = await analyticsService.generateSystemAnalytics();
5872

59-
logger.info("System analytics retrieved");
73+
// Paginate monthlyGrowth
74+
const all = analytics.monthlyGrowth;
75+
const total = all.length;
76+
const offset = (page - 1) * limit;
77+
const pagedGrowth = all.slice(offset, offset + limit);
78+
79+
logger.info("System analytics retrieved", { page, limit });
6080
return res.status(200).json({
6181
success: true,
62-
data: analytics,
82+
data: {
83+
...analytics,
84+
monthlyGrowth: pagedGrowth,
85+
},
86+
pagination: {
87+
page,
88+
limit,
89+
total,
90+
totalPages: Math.max(1, Math.ceil(total / limit)),
91+
hasNextPage: offset + limit < total,
92+
hasPreviousPage: page > 1,
93+
},
6394
timestamp: new Date().toISOString(),
6495
});
6596
} catch (error) {
@@ -71,6 +102,43 @@ router.get("/system", async (req, res) => {
71102
}
72103
});
73104

105+
/**
106+
* GET /api/analytics/monthly-growth
107+
* Get paginated monthly growth data
108+
* Query params: page (default 1), limit (default 12)
109+
*/
110+
router.get("/monthly-growth", async (req, res) => {
111+
try {
112+
const errors: ValidationError[] = [];
113+
114+
const rawPage = sanitizeInteger(req.query.page ?? '1', 1, 10_000);
115+
const rawLimit = sanitizeInteger(req.query.limit ?? '12', 1, 100);
116+
117+
if (req.query.page !== undefined && Number.isNaN(rawPage)) errors.push(validationError('page', 'page must be a positive integer (1–10000)'));
118+
if (req.query.limit !== undefined && Number.isNaN(rawLimit)) errors.push(validationError('limit', 'limit must be an integer between 1 and 100'));
119+
if (errors.length > 0) return res.status(400).json({ success: false, errors });
120+
121+
const page = Number.isNaN(rawPage) ? 1 : rawPage;
122+
const limit = Number.isNaN(rawLimit) ? 12 : rawLimit;
123+
124+
const result = await analyticsService.getPaginatedMonthlyGrowth({ page, limit });
125+
126+
logger.info("Monthly growth analytics retrieved", { page, limit });
127+
return res.status(200).json({
128+
success: true,
129+
data: result.data,
130+
pagination: result.pagination,
131+
timestamp: new Date().toISOString(),
132+
});
133+
} catch (error) {
134+
logger.error("Failed to retrieve monthly growth analytics", { error });
135+
return res.status(500).json({
136+
success: false,
137+
error: "Failed to retrieve monthly growth analytics",
138+
});
139+
}
140+
});
141+
74142
/**
75143
* GET /api/analytics/payments/dashboard
76144
* Get payment analytics dashboard insights

backend/src/services/analyticsService.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,23 @@ export interface AnalyticsTrendPoint {
6767
count?: number;
6868
}
6969

70+
export interface PaginationParams {
71+
page: number;
72+
limit: number;
73+
}
74+
75+
export interface PaginatedResult<T> {
76+
data: T[];
77+
pagination: {
78+
page: number;
79+
limit: number;
80+
total: number;
81+
totalPages: number;
82+
hasNextPage: boolean;
83+
hasPreviousPage: boolean;
84+
};
85+
}
86+
7087
export interface AnalyticsReport {
7188
userId: string;
7289
totalSpendYearly: number;
@@ -528,6 +545,31 @@ export class AnalyticsService {
528545
);
529546
}
530547

548+
/**
549+
* Get paginated monthly growth data from system analytics
550+
*/
551+
async getPaginatedMonthlyGrowth(
552+
params: PaginationParams,
553+
): Promise<PaginatedResult<AnalyticsTrendPoint>> {
554+
const { page, limit } = params;
555+
const systemAnalytics = await this.generateSystemAnalytics();
556+
const all = systemAnalytics.monthlyGrowth;
557+
const total = all.length;
558+
const offset = (page - 1) * limit;
559+
const data = all.slice(offset, offset + limit);
560+
return {
561+
data,
562+
pagination: {
563+
page,
564+
limit,
565+
total,
566+
totalPages: Math.max(1, Math.ceil(total / limit)),
567+
hasNextPage: offset + limit < total,
568+
hasPreviousPage: page > 1,
569+
},
570+
};
571+
}
572+
531573
/**
532574
* Legacy method for backward compatibility
533575
*/

0 commit comments

Comments
 (0)