Skip to content

Commit 32bc3d4

Browse files
authored
feat: merge secure admin payment dashboard (#17)
Merge PR #17 from Jidnyasa-P Add a secure admin payment dashboard with secure payment record management for administrators. Highlights: - Add admin payment records dashboard - Sanitize payment data returned by the API - Support search, filtering, sorting, and pagination - Display payment summary metrics - Add responsive desktop and mobile layouts - Add accessible transaction details dialog - Protect endpoints with existing admin authentication and authorization - Add API documentation and testing guidance - Verify implementation with backend validation, production build, authorization checks, and feature testing Closes #17. P.S. While merging PR #16, I accidentally used this PR's merge title and description. So now PR #16 has PR #17's merge message, and PR #17 gets PR #16's. They basically swapped identities.
1 parent fe20023 commit 32bc3d4

6 files changed

Lines changed: 1443 additions & 0 deletions

File tree

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
const mongoose = require("mongoose");
2+
const coursePaymentSchema = require("../schemas/coursePaymentModel");
3+
4+
const ALLOWED_STATUSES = new Set([
5+
"successful",
6+
"pending",
7+
"failed",
8+
]);
9+
10+
const ALLOWED_SORTS = new Set([
11+
"newest",
12+
"oldest",
13+
"amount-asc",
14+
"amount-desc",
15+
]);
16+
17+
const escapeRegex = (value = "") =>
18+
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19+
20+
const parsePositiveInteger = (value, fallback, maximum) => {
21+
const parsed = Number.parseInt(value, 10);
22+
23+
if (!Number.isFinite(parsed) || parsed < 1) {
24+
return fallback;
25+
}
26+
27+
return Math.min(parsed, maximum);
28+
};
29+
30+
const parseDateBoundary = (value, endOfDay = false) => {
31+
if (!value) return null;
32+
33+
const parsed = new Date(value);
34+
35+
if (Number.isNaN(parsed.getTime())) {
36+
return null;
37+
}
38+
39+
if (endOfDay && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
40+
parsed.setHours(23, 59, 59, 999);
41+
}
42+
43+
return parsed;
44+
};
45+
46+
const parseAmount = (value) => {
47+
if (typeof value === "number" && Number.isFinite(value)) {
48+
return value;
49+
}
50+
51+
const parsed = Number.parseFloat(
52+
String(value || "").replace(/[^0-9.-]/g, ""),
53+
);
54+
55+
return Number.isFinite(parsed) ? parsed : 0;
56+
};
57+
58+
const normalizeStatus = (status) => {
59+
const normalized = String(status || "").trim().toLowerCase();
60+
61+
if (
62+
["success", "successful", "completed", "paid", "enrolled"].includes(
63+
normalized,
64+
)
65+
) {
66+
return "successful";
67+
}
68+
69+
if (["failed", "declined", "rejected", "cancelled"].includes(normalized)) {
70+
return "failed";
71+
}
72+
73+
return "pending";
74+
};
75+
76+
const maskCardNumber = (cardNumber) => {
77+
const digits = String(cardNumber || "").replace(/\D/g, "");
78+
79+
if (digits.length < 4) {
80+
return null;
81+
}
82+
83+
return `•••• •••• •••• ${digits.slice(-4)}`;
84+
};
85+
86+
const buildSanitizedPayment = (payment) => {
87+
const amount = parseAmount(payment.courseId?.C_price);
88+
const safeStatus = normalizeStatus(payment.status);
89+
90+
return {
91+
id: String(payment._id),
92+
student: {
93+
id: payment.userId?._id ? String(payment.userId._id) : null,
94+
name: payment.userId?.name || null,
95+
email: payment.userId?.email || "Unknown",
96+
},
97+
course: {
98+
id: payment.courseId?._id ? String(payment.courseId._id) : null,
99+
title: payment.courseId?.C_title || "Deleted or unavailable course",
100+
},
101+
amount,
102+
currency: "INR",
103+
status: safeStatus,
104+
createdAt: payment.createdAt || null,
105+
updatedAt: payment.updatedAt || null,
106+
maskedCard: maskCardNumber(payment.cardDetails?.cardnumber),
107+
};
108+
};
109+
110+
const getAdminPaymentsController = async (req, res) => {
111+
try {
112+
const page = parsePositiveInteger(req.query.page, 1, 100000);
113+
const limit = parsePositiveInteger(req.query.limit, 10, 50);
114+
const search = String(req.query.search || "").trim().slice(0, 120);
115+
const status = String(req.query.status || "").trim().toLowerCase();
116+
const sort = String(req.query.sort || "newest").trim().toLowerCase();
117+
118+
if (status && !ALLOWED_STATUSES.has(status)) {
119+
return res.status(400).send({
120+
success: false,
121+
message: "Invalid payment status filter.",
122+
});
123+
}
124+
125+
if (!ALLOWED_SORTS.has(sort)) {
126+
return res.status(400).send({
127+
success: false,
128+
message: "Invalid payment sort option.",
129+
});
130+
}
131+
132+
const startDate = parseDateBoundary(req.query.startDate);
133+
const endDate = parseDateBoundary(req.query.endDate, true);
134+
135+
if (req.query.startDate && !startDate) {
136+
return res.status(400).send({
137+
success: false,
138+
message: "Invalid start date.",
139+
});
140+
}
141+
142+
if (req.query.endDate && !endDate) {
143+
return res.status(400).send({
144+
success: false,
145+
message: "Invalid end date.",
146+
});
147+
}
148+
149+
if (startDate && endDate && startDate > endDate) {
150+
return res.status(400).send({
151+
success: false,
152+
message: "Start date cannot be after end date.",
153+
});
154+
}
155+
156+
const match = {};
157+
158+
if (startDate || endDate) {
159+
match.createdAt = {};
160+
if (startDate) match.createdAt.$gte = startDate;
161+
if (endDate) match.createdAt.$lte = endDate;
162+
}
163+
164+
const records = await coursePaymentSchema
165+
.find(match)
166+
.select(
167+
"_id userId courseId status createdAt updatedAt +cardDetails.cardnumber",
168+
)
169+
.populate("userId", "name email")
170+
.populate("courseId", "C_title C_price")
171+
.lean();
172+
173+
let sanitizedPayments = records.map(buildSanitizedPayment);
174+
175+
if (search) {
176+
const safeSearch = escapeRegex(search);
177+
const searchPattern = new RegExp(safeSearch, "i");
178+
179+
sanitizedPayments = sanitizedPayments.filter((payment) =>
180+
[
181+
payment.id,
182+
payment.student.email,
183+
payment.student.name,
184+
payment.course.title,
185+
].some((field) => searchPattern.test(String(field || ""))),
186+
);
187+
}
188+
189+
if (status) {
190+
sanitizedPayments = sanitizedPayments.filter(
191+
(payment) => payment.status === status,
192+
);
193+
}
194+
195+
const sorters = {
196+
newest: (a, b) =>
197+
new Date(b.createdAt || 0) - new Date(a.createdAt || 0),
198+
oldest: (a, b) =>
199+
new Date(a.createdAt || 0) - new Date(b.createdAt || 0),
200+
"amount-asc": (a, b) => a.amount - b.amount,
201+
"amount-desc": (a, b) => b.amount - a.amount,
202+
};
203+
204+
sanitizedPayments.sort(sorters[sort]);
205+
206+
const summary = sanitizedPayments.reduce(
207+
(current, payment) => {
208+
current.totalTransactions += 1;
209+
current[payment.status] += 1;
210+
211+
if (payment.status === "successful") {
212+
current.totalRevenue += payment.amount;
213+
}
214+
215+
return current;
216+
},
217+
{
218+
totalTransactions: 0,
219+
successful: 0,
220+
pending: 0,
221+
failed: 0,
222+
totalRevenue: 0,
223+
},
224+
);
225+
226+
const totalItems = sanitizedPayments.length;
227+
const totalPages = Math.max(1, Math.ceil(totalItems / limit));
228+
const safePage = Math.min(page, totalPages);
229+
const startIndex = (safePage - 1) * limit;
230+
const paginatedPayments = sanitizedPayments.slice(
231+
startIndex,
232+
startIndex + limit,
233+
);
234+
235+
return res.status(200).send({
236+
success: true,
237+
data: paginatedPayments,
238+
summary,
239+
pagination: {
240+
page: safePage,
241+
limit,
242+
totalItems,
243+
totalPages,
244+
hasPreviousPage: safePage > 1,
245+
hasNextPage: safePage < totalPages,
246+
},
247+
filters: {
248+
search,
249+
status,
250+
startDate: startDate?.toISOString() || null,
251+
endDate: endDate?.toISOString() || null,
252+
sort,
253+
},
254+
});
255+
} catch (error) {
256+
console.error("Unable to retrieve payment records:", error);
257+
258+
return res.status(500).send({
259+
success: false,
260+
message: "Unable to retrieve payment records.",
261+
});
262+
}
263+
};
264+
265+
module.exports = {
266+
getAdminPaymentsController,
267+
};

backend/routers/adminRoutes.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const {
1515
} = require("../controllers/adminController");
1616

1717
const {
18+
getAdminPaymentsController,
19+
} = require("../controllers/paymentRecordsController");
1820
getActivityLogsController,
1921
} = require("../controllers/activityLogController");
2022

@@ -40,6 +42,25 @@ router.get(
4042
"/payments",
4143
authMiddleware,
4244
checkRole(["admin"]),
45+
getAdminPaymentsController,
46+
);
47+
48+
router.get(
49+
"/getallcourses",
50+
authMiddleware,
51+
checkRole(["admin"]),
52+
getAllCoursesController,
53+
);
54+
55+
router.delete(
56+
"/deletecourse/:courseid",
57+
authMiddleware,
58+
checkRole(["admin"]),
59+
deleteCourseController,
60+
);
61+
62+
router.delete(
63+
"/deleteuser/:userid",
4364
getAllPaymentsController,
4465
);
4566

docs/admin-payment-records.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Admin Payment Records
2+
3+
Issue: `feat: add admin payment records dashboard with safe transaction details`
4+
5+
## Security-first design
6+
7+
The legacy `/api/admin/payments` controller returned complete Mongoose payment
8+
documents. Those documents contain nested mock card details, including full card
9+
numbers and CVV values.
10+
11+
The replacement controller:
12+
13+
- Returns a strict, manually constructed DTO.
14+
- Never returns CVV, expiry, password, JWT, or raw card detail objects.
15+
- Returns only a masked card identifier when a usable number exists.
16+
- Escapes regex search input.
17+
- Validates filters, dates, sorting, page, and page size.
18+
- Uses the existing admin authentication and authorization middleware.
19+
20+
## Endpoint
21+
22+
```http
23+
GET /api/admin/payments
24+
Authorization: Bearer <admin-token>
25+
```
26+
27+
Query parameters:
28+
29+
| Parameter | Supported values |
30+
|---|---|
31+
| `page` | Positive integer |
32+
| `limit` | 1–50 |
33+
| `search` | Transaction ID, student name/email, or course title |
34+
| `status` | `successful`, `pending`, `failed` |
35+
| `startDate` | ISO date or `YYYY-MM-DD` |
36+
| `endDate` | ISO date or `YYYY-MM-DD` |
37+
| `sort` | `newest`, `oldest`, `amount-asc`, `amount-desc` |
38+
39+
## Status compatibility
40+
41+
Existing payment documents default to `enrolled`. The API normalizes legacy
42+
values as follows:
43+
44+
- `enrolled`, `paid`, `completed`, `success``successful`
45+
- `declined`, `rejected`, `cancelled``failed`
46+
- Unknown values → `pending`
47+
48+
No data migration is required.
49+
50+
## Amount
51+
52+
The original payment model does not contain a separate amount field. The API
53+
uses the populated course's `C_price` value and safely parses its numeric amount.
54+
55+
## Frontend
56+
57+
The Payments section provides:
58+
59+
- Transaction, success, attention, and mock-revenue summaries
60+
- Search
61+
- Status and date filters
62+
- Newest/oldest and amount sorting
63+
- Pagination and page size
64+
- Loading, empty, and retry states
65+
- Responsive desktop and mobile layouts
66+
- A safe transaction-details dialog
67+
68+
## Testing security
69+
70+
Open the browser Network tab and inspect `/api/admin/payments`.
71+
72+
The response must not contain:
73+
74+
```text
75+
cvvcode
76+
expmonthyear
77+
password
78+
token
79+
cardDetails
80+
```
81+
82+
A masked value such as `•••• •••• •••• 1234` may appear as `maskedCard`.

0 commit comments

Comments
 (0)