diff --git a/src/__tests__/admin.kyc.test.js b/src/__tests__/admin.kyc.test.js new file mode 100644 index 0000000..dea530e --- /dev/null +++ b/src/__tests__/admin.kyc.test.js @@ -0,0 +1,134 @@ +const jwt = require('jsonwebtoken'); +const request = require('supertest'); + +jest.mock('../models/User.model', () => ({ + findById: jest.fn(), +})); + +jest.mock('../services/email.service', () => ({ + sendEmail: jest.fn().mockResolvedValue({ messageId: 'test' }), +})); + +const User = require('../models/User.model'); +const { sendEmail } = require('../services/email.service'); +const app = require('../app'); + +describe('PATCH /api/admin/kyc/:id', () => { + const adminId = '507f1f77bcf86cd799439010'; + const targetId = '507f1f77bcf86cd799439011'; + const secret = process.env.JWT_SECRET || 'test-secret'; + + const adminToken = jwt.sign({ sub: adminId, type: 'access' }, secret, { + expiresIn: '1h', + }); + const userToken = jwt.sign({ sub: targetId, type: 'access' }, secret, { + expiresIn: '1h', + }); + + const adminUser = { _id: adminId, role: 'admin', email: 'admin@test.io' }; + + const buildTargetUser = () => ({ + _id: targetId, + id: targetId, + role: 'user', + email: 'user@test.io', + fullName: 'Jane Doe', + kycStatus: 'pending', + kycReviewNotes: null, + save: jest.fn().mockResolvedValue(true), + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('requires authentication', async () => { + const response = await request(app) + .patch(`/api/admin/kyc/${targetId}`) + .send({ status: 'approved' }) + .expect(401); + + expect(response.body.success).toBe(false); + }); + + it('rejects non-admin users', async () => { + User.findById.mockResolvedValueOnce({ _id: targetId, role: 'user' }); + + const response = await request(app) + .patch(`/api/admin/kyc/${targetId}`) + .set('Authorization', `Bearer ${userToken}`) + .send({ status: 'approved' }) + .expect(403); + + expect(response.body.success).toBe(false); + }); + + it('rejects an invalid status value', async () => { + User.findById.mockResolvedValueOnce(adminUser); + + const response = await request(app) + .patch(`/api/admin/kyc/${targetId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ status: 'maybe' }) + .expect(400); + + expect(response.body.success).toBe(false); + }); + + it('returns 404 when the target user does not exist', async () => { + User.findById + .mockResolvedValueOnce(adminUser) // auth middleware + .mockResolvedValueOnce(null); // controller lookup + + const response = await request(app) + .patch(`/api/admin/kyc/${targetId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ status: 'approved' }) + .expect(404); + + expect(response.body.success).toBe(false); + expect(response.body.message).toBe('User not found'); + }); + + it('approves a KYC submission and emails the user', async () => { + const target = buildTargetUser(); + User.findById + .mockResolvedValueOnce(adminUser) // auth middleware + .mockResolvedValueOnce(target); // controller lookup + + const response = await request(app) + .patch(`/api/admin/kyc/${targetId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ status: 'approved', reviewNote: 'Documents verified' }) + .expect(200); + + expect(target.kycStatus).toBe('approved'); + expect(target.kycReviewNotes).toBe('Documents verified'); + expect(target.save).toHaveBeenCalledTimes(1); + + expect(sendEmail).toHaveBeenCalledTimes(1); + expect(sendEmail.mock.calls[0][0].to).toBe('user@test.io'); + + expect(response.body.success).toBe(true); + expect(response.body.data.kycStatus).toBe('approved'); + expect(response.body.data.kycReviewNotes).toBe('Documents verified'); + }); + + it('rejects a KYC submission', async () => { + const target = buildTargetUser(); + User.findById + .mockResolvedValueOnce(adminUser) + .mockResolvedValueOnce(target); + + const response = await request(app) + .patch(`/api/admin/kyc/${targetId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ status: 'rejected' }) + .expect(200); + + expect(target.kycStatus).toBe('rejected'); + expect(target.save).toHaveBeenCalledTimes(1); + expect(sendEmail).toHaveBeenCalledTimes(1); + expect(response.body.data.kycStatus).toBe('rejected'); + }); +}); diff --git a/src/controllers/admin.kyc.controller.js b/src/controllers/admin.kyc.controller.js new file mode 100644 index 0000000..f3bc087 --- /dev/null +++ b/src/controllers/admin.kyc.controller.js @@ -0,0 +1,62 @@ +const User = require('../models/User.model'); +const { sendSuccess } = require('../utils/response'); +const { sendEmail } = require('../services/email.service'); +const kycDecisionTemplate = require('../services/templates/kycDecision.template'); + +/** + * Review a user's KYC submission (admin only) + * Updates the user's kycStatus and notifies them of the decision by email. + * @route PATCH /api/admin/kyc/:id + * @access Admin only + */ +const reviewKyc = async (req, res, next) => { + try { + const { id } = req.params; + const { status, reviewNote } = req.body; + + const user = await User.findById(id); + if (!user) { + const error = new Error('User not found'); + error.statusCode = 404; + error.isOperational = true; + return next(error); + } + + // Apply the review decision + user.kycStatus = status; + if (reviewNote !== undefined) { + user.kycReviewNotes = reviewNote; + } + await user.save(); + + // Notify the user of the decision (non-blocking — review still succeeds if email fails) + try { + await sendEmail({ + to: user.email, + subject: `Your KYC submission has been ${status}`, + html: kycDecisionTemplate(user.fullName, status, user.kycReviewNotes), + }); + } catch (emailError) { + console.error('Failed to send KYC decision email:', emailError.message); + } + + return sendSuccess( + res, + { + id: user.id, + email: user.email, + fullName: user.fullName, + kycStatus: user.kycStatus, + kycReviewNotes: user.kycReviewNotes || null, + }, + 200, + `KYC submission ${status} successfully` + ); + } catch (error) { + next(error); + } +}; + +module.exports = { + reviewKyc, +}; diff --git a/src/routes/admin.routes.js b/src/routes/admin.routes.js index 1bc0564..9c22b4e 100644 --- a/src/routes/admin.routes.js +++ b/src/routes/admin.routes.js @@ -1,6 +1,7 @@ const express = require('express'); const authenticate = require('../middlewares/auth'); const isAdmin = require('../middlewares/isAdmin'); +const validate = require('../middlewares/validate'); const { deleteUser, restoreUser, @@ -8,6 +9,8 @@ const { updateUserStatus, updateUserRole, } = require('../controllers/admin.users.controller'); +const { reviewKyc } = require('../controllers/admin.kyc.controller'); +const { reviewKycSchema } = require('../validators/admin.validators'); const router = express.Router(); @@ -24,6 +27,8 @@ router.delete('/users/:id', deleteUser); // POST /api/admin/users/:id/restore - Restore a soft-deleted user router.post('/users/:id/restore', restoreUser); +// PATCH /api/admin/kyc/:id - Review a user's KYC submission +router.patch('/kyc/:id', validate(reviewKycSchema), reviewKyc); // PATCH /api/admin/users/:id/status - Suspend or activate a user router.patch('/users/:id/status', updateUserStatus); // PATCH /api/admin/users/:id/role - Update a user role diff --git a/src/services/templates/kycDecision.template.js b/src/services/templates/kycDecision.template.js new file mode 100644 index 0000000..b339597 --- /dev/null +++ b/src/services/templates/kycDecision.template.js @@ -0,0 +1,130 @@ +/** + * KYC Decision Template + * Notifies a user about the outcome of their KYC review (approved or rejected) + */ + +const kycDecisionTemplate = (userName, status, reviewNote = null) => { + const isApproved = status === 'approved'; + + const accent = isApproved + ? 'linear-gradient(135deg, #84fab0 0%, #8fd3f4 100%)' + : 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)'; + const emoji = isApproved ? '✅' : '⚠️'; + const heading = isApproved ? 'KYC Approved' : 'KYC Update'; + const message = isApproved + ? `Great news! Your identity verification (KYC) has been approved. You now have full access to all StellarAid features.` + : `Thank you for submitting your identity verification (KYC). After review, we were unable to approve your submission at this time.`; + + return ` + + + + + + + + +
+
+
${emoji}
+

${heading}

+
+ +
+
Hi ${userName},
+ +
${message}
+ + ${ + reviewNote + ? `
Reviewer note:
${reviewNote}
` + : '' + } + + ${ + isApproved + ? '' + : `
+ You may update your details and resubmit, or contact our support team if you believe this was a mistake. +
` + } +
+ + +
+ + + `; +}; + +module.exports = kycDecisionTemplate; diff --git a/src/validators/admin.validators.js b/src/validators/admin.validators.js new file mode 100644 index 0000000..36e2fae --- /dev/null +++ b/src/validators/admin.validators.js @@ -0,0 +1,15 @@ +const Joi = require('joi'); + +const reviewKycSchema = Joi.object({ + status: Joi.string().valid('approved', 'rejected').required().messages({ + 'any.only': "Status must be either 'approved' or 'rejected'", + 'any.required': 'Status is required', + }), + reviewNote: Joi.string().trim().max(1000).messages({ + 'string.max': 'Review note cannot exceed 1000 characters', + }), +}); + +module.exports = { + reviewKycSchema, +};