Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions src/__tests__/admin.kyc.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
62 changes: 62 additions & 0 deletions src/controllers/admin.kyc.controller.js
Original file line number Diff line number Diff line change
@@ -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,
};
5 changes: 5 additions & 0 deletions src/routes/admin.routes.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
const express = require('express');
const authenticate = require('../middlewares/auth');
const isAdmin = require('../middlewares/isAdmin');
const validate = require('../middlewares/validate');
const {
deleteUser,
restoreUser,
listUsers,
updateUserStatus,
updateUserRole,
} = require('../controllers/admin.users.controller');
const { reviewKyc } = require('../controllers/admin.kyc.controller');
const { reviewKycSchema } = require('../validators/admin.validators');

const router = express.Router();

Expand All @@ -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
Expand Down
130 changes: 130 additions & 0 deletions src/services/templates/kycDecision.template.js
Original file line number Diff line number Diff line change
@@ -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 `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: ${accent};
color: #ffffff;
padding: 30px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 28px;
font-weight: 600;
}
.emoji {
font-size: 40px;
margin-bottom: 10px;
}
.content {
padding: 40px 30px;
}
.greeting {
font-size: 18px;
margin-bottom: 20px;
color: #333;
}
.message {
font-size: 16px;
color: #666;
margin-bottom: 30px;
line-height: 1.8;
}
.note {
background-color: #f8f9fa;
border-left: 4px solid ${isApproved ? '#84fab0' : '#f5576c'};
padding: 15px 20px;
margin: 25px 0;
border-radius: 4px;
font-size: 15px;
color: #555;
}
.footer {
background-color: #f9f9f9;
padding: 30px;
text-align: center;
border-top: 1px solid #eee;
font-size: 12px;
color: #999;
}
.footer p {
margin: 5px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="emoji">${emoji}</div>
<h1>${heading}</h1>
</div>

<div class="content">
<div class="greeting">Hi ${userName},</div>

<div class="message">${message}</div>

${
reviewNote
? `<div class="note"><strong>Reviewer note:</strong><br>${reviewNote}</div>`
: ''
}

${
isApproved
? ''
: `<div class="message" style="font-size: 14px; color: #999;">
You may update your details and resubmit, or contact our support team if you believe this was a mistake.
</div>`
}
</div>

<div class="footer">
<p>&copy; 2026 StellarAid. All rights reserved.</p>
<p>This is an automated email. Please do not reply directly.</p>
<p>If you need help, contact our support team.</p>
</div>
</div>
</body>
</html>
`;
};

module.exports = kycDecisionTemplate;
15 changes: 15 additions & 0 deletions src/validators/admin.validators.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading