From 645e69f70c24617776856359f3361925c0e4b229 Mon Sep 17 00:00:00 2001 From: emarc99 <57766083+emarc99@users.noreply.github.com> Date: Fri, 26 Jun 2026 02:40:40 +0100 Subject: [PATCH 1/4] feat: create KYC schema and database model --- src/models/KYC.model.js | 46 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/models/KYC.model.js diff --git a/src/models/KYC.model.js b/src/models/KYC.model.js new file mode 100644 index 0000000..d92dd24 --- /dev/null +++ b/src/models/KYC.model.js @@ -0,0 +1,46 @@ +const mongoose = require('mongoose'); + +const kycSchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + documentType: { + type: String, + required: true, + trim: true, + }, + documentUrl: { + type: String, + required: true, + trim: true, + }, + status: { + type: String, + enum: ['pending', 'approved', 'rejected'], + default: 'pending', + index: true, + }, + submittedAt: { + type: Date, + default: Date.now, + }, + reviewedAt: { + type: Date, + default: null, + }, + reviewNote: { + type: String, + default: null, + trim: true, + }, + }, + { timestamps: true } +); + +const KYC = mongoose.model('KYC', kycSchema); + +module.exports = KYC; From 0ceb561e9fe40c053f2c6307954b558591eae774 Mon Sep 17 00:00:00 2001 From: emarc99 <57766083+emarc99@users.noreply.github.com> Date: Fri, 26 Jun 2026 02:41:14 +0100 Subject: [PATCH 2/4] feat: add KYC document upload middleware and request validation --- src/middlewares/kycUpload.middleware.js | 72 +++++++++++++++++++++++++ src/validators/auth.validators.js | 12 +++++ 2 files changed, 84 insertions(+) create mode 100644 src/middlewares/kycUpload.middleware.js diff --git a/src/middlewares/kycUpload.middleware.js b/src/middlewares/kycUpload.middleware.js new file mode 100644 index 0000000..f61fdbf --- /dev/null +++ b/src/middlewares/kycUpload.middleware.js @@ -0,0 +1,72 @@ +const multer = require('multer'); +const path = require('path'); +const crypto = require('crypto'); +const fs = require('fs'); + +const ALLOWED_MIME_TYPES = [ + 'application/pdf', + 'image/jpeg', + 'image/png', +]; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + +// Ensure upload directory exists +const uploadDir = path.join(process.cwd(), 'uploads', 'kyc'); +if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); +} + +const storage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, uploadDir), + filename: (req, file, cb) => { + const uniqueSuffix = crypto.randomBytes(16).toString('hex'); + const ext = path.extname(file.originalname).toLowerCase(); + cb(null, `kyc-${req.userId || 'anonymous'}-${uniqueSuffix}${ext}`); + }, +}); + +const fileFilter = (_req, file, cb) => { + if (ALLOWED_MIME_TYPES.includes(file.mimetype)) { + cb(null, true); + } else { + const error = new Error( + 'Invalid file type. Only PDF, JPG, and PNG are allowed.' + ); + error.statusCode = 400; + error.isOperational = true; + cb(error, false); + } +}; + +const upload = multer({ + storage, + fileFilter, + limits: { + fileSize: MAX_FILE_SIZE, + }, +}); + +// Wrap multer to convert its errors into operational errors your errorHandler understands +const handleKycUpload = (req, res, next) => { + upload.single('document')(req, res, (err) => { + if (!err) return next(); + + if (err.code === 'LIMIT_FILE_SIZE') { + err.message = 'The file must be 5MB or smaller.'; + err.statusCode = 400; + err.isOperational = true; + } else if (err.code === 'LIMIT_UNEXPECTED_FILE') { + err.message = 'Unexpected field name. Use "document" as the field name.'; + err.statusCode = 400; + err.isOperational = true; + } else if (!err.statusCode) { + err.statusCode = 400; + err.isOperational = true; + } + + return next(err); + }); +}; + +module.exports = { handleKycUpload }; diff --git a/src/validators/auth.validators.js b/src/validators/auth.validators.js index 86c1867..fb99602 100644 --- a/src/validators/auth.validators.js +++ b/src/validators/auth.validators.js @@ -79,6 +79,17 @@ const updateProfileSchema = Joi.object({ 'object.min': 'At least one field (fullName or walletAddress) must be provided', }); +const kycSubmissionSchema = Joi.object({ + documentType: Joi.string() + .valid('passport', 'national_id', 'driving_license') + .required() + .messages({ + 'any.only': 'Document type must be one of: passport, national_id, driving_license', + 'any.required': 'Document type is required', + 'string.empty': 'Document type cannot be empty', + }), +}); + module.exports = { registerSchema, loginSchema, @@ -87,4 +98,5 @@ module.exports = { refreshTokenSchema, changePasswordSchema, updateProfileSchema, + kycSubmissionSchema, }; \ No newline at end of file From f33bf2cf2250e01a599aad2a33e612c8848af456 Mon Sep 17 00:00:00 2001 From: emarc99 <57766083+emarc99@users.noreply.github.com> Date: Fri, 26 Jun 2026 02:41:37 +0100 Subject: [PATCH 3/4] feat: implement POST /api/users/me/kyc endpoint and controller --- src/controllers/users.controller.js | 52 +++++++++++++++++++++++++++++ src/routes/users.routes.js | 7 +++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js index 525aa97..56e7a9b 100644 --- a/src/controllers/users.controller.js +++ b/src/controllers/users.controller.js @@ -1,4 +1,5 @@ const User = require('../models/User.model'); +const KYC = require('../models/KYC.model'); const { sendSuccess } = require('../utils/response'); const multer = require('multer'); const path = require('path'); @@ -159,10 +160,61 @@ const uploadAvatar = async (req, res, next) => { } }; +/** + * Submit KYC documents + * @route POST /api/users/me/kyc + * @access Private (requires authentication) + */ +const submitKyc = async (req, res, next) => { + try { + // 1. Check if user already has a pending or approved KYC submission + if (req.user.kycStatus === 'pending' || req.user.kycStatus === 'approved') { + const error = new Error('KYC submission already exists or is already approved'); + error.statusCode = 400; + error.isOperational = true; + return next(error); + } + + // 2. Ensure a file was uploaded + if (!req.file) { + const error = new Error('KYC document file is required'); + error.statusCode = 400; + error.isOperational = true; + return next(error); + } + + const { documentType } = req.body; + + // 3. Create document URL/relative path + const documentUrl = `uploads/kyc/${req.file.filename}`; + + // 4. Create the KYC record + const kyc = await KYC.create({ + userId: req.userId, + documentType, + documentUrl, + status: 'pending', + submittedAt: new Date(), + }); + + // 5. Update the User's KYC status fields + req.user.kycStatus = 'pending'; + req.user.kycSubmissionDate = kyc.submittedAt; + req.user.kycReviewNotes = null; + await req.user.save(); + + // 6. Return response + return sendSuccess(res, kyc, 201, 'KYC document submitted successfully'); + } catch (error) { + next(error); + } +}; + module.exports = { getCurrentUser, getCurrentUserKyc, updateCurrentUser, uploadAvatar, + submitKyc, upload // Export multer upload middleware }; \ No newline at end of file diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js index 5c30e98..4e27f89 100644 --- a/src/routes/users.routes.js +++ b/src/routes/users.routes.js @@ -5,10 +5,12 @@ const { getCurrentUserKyc, updateCurrentUser, uploadAvatar, + submitKyc, upload } = require('../controllers/users.controller'); -const { updateProfileSchema } = require('../validators/auth.validators'); +const { updateProfileSchema, kycSubmissionSchema } = require('../validators/auth.validators'); const validate = require('../middlewares/validate'); +const { handleKycUpload } = require('../middlewares/kycUpload.middleware'); const router = express.Router(); @@ -24,4 +26,7 @@ router.patch('/me', authenticate, validate(updateProfileSchema), updateCurrentUs // POST /api/users/me/avatar - Upload profile picture/avatar router.post('/me/avatar', authenticate, upload.single('avatar'), uploadAvatar); +// POST /api/users/me/kyc - Submit KYC document +router.post('/me/kyc', authenticate, handleKycUpload, validate(kycSubmissionSchema), submitKyc); + module.exports = router; \ No newline at end of file From 1a42a37929e095da4ea5823956df6c2525a0b87d Mon Sep 17 00:00:00 2001 From: emarc99 <57766083+emarc99@users.noreply.github.com> Date: Fri, 26 Jun 2026 02:41:50 +0100 Subject: [PATCH 4/4] test: add unit and integration tests for POST /api/users/me/kyc --- src/__tests__/users.kyc.test.js | 197 ++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/src/__tests__/users.kyc.test.js b/src/__tests__/users.kyc.test.js index 123d9dd..521fb22 100644 --- a/src/__tests__/users.kyc.test.js +++ b/src/__tests__/users.kyc.test.js @@ -1,11 +1,18 @@ const jwt = require('jsonwebtoken'); const request = require('supertest'); +const fs = require('fs'); +const path = require('path'); jest.mock('../models/User.model', () => ({ findById: jest.fn(), })); +jest.mock('../models/KYC.model', () => ({ + create: jest.fn(), +})); + const User = require('../models/User.model'); +const KYC = require('../models/KYC.model'); const app = require('../app'); describe('GET /api/users/me/kyc', () => { @@ -71,3 +78,193 @@ describe('GET /api/users/me/kyc', () => { }); }); }); + +describe('POST /api/users/me/kyc', () => { + const userId = '507f1f77bcf86cd799439011'; + let token; + const dummyFilePath = path.join(__dirname, 'dummy.png'); + + beforeAll(() => { + token = jwt.sign( + { sub: userId, type: 'access' }, + process.env.JWT_SECRET || 'test-secret', + { expiresIn: '1h' } + ); + // Create dummy file for attachment + fs.writeFileSync(dummyFilePath, 'dummy content'); + }); + + afterAll(() => { + if (fs.existsSync(dummyFilePath)) { + fs.unlinkSync(dummyFilePath); + } + // Clean up uploaded files in uploads/kyc + const kycUploadDir = path.join(process.cwd(), 'uploads', 'kyc'); + if (fs.existsSync(kycUploadDir)) { + const files = fs.readdirSync(kycUploadDir); + for (const file of files) { + if (file.startsWith(`kyc-${userId}-`)) { + fs.unlinkSync(path.join(kycUploadDir, file)); + } + } + } + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('requires authentication', async () => { + const response = await request(app) + .post('/api/users/me/kyc') + .field('documentType', 'passport') + .expect(401); + + expect(response.body.success).toBe(false); + expect(response.body.message).toBe('Authentication required'); + }); + + it('rejects duplicate submission if user has a pending KYC status', async () => { + const mockUser = { + _id: userId, + kycStatus: 'pending', + kycSubmissionDate: null, + kycReviewNotes: null, + save: jest.fn().mockResolvedValue(true), + }; + User.findById.mockResolvedValue(mockUser); + + const response = await request(app) + .post('/api/users/me/kyc') + .set('Authorization', `Bearer ${token}`) + .field('documentType', 'passport') + .attach('document', dummyFilePath) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.message).toBe('KYC submission already exists or is already approved'); + }); + + it('rejects duplicate submission if user has an approved KYC status', async () => { + const mockUser = { + _id: userId, + kycStatus: 'approved', + kycSubmissionDate: new Date(), + kycReviewNotes: null, + save: jest.fn().mockResolvedValue(true), + }; + User.findById.mockResolvedValue(mockUser); + + const response = await request(app) + .post('/api/users/me/kyc') + .set('Authorization', `Bearer ${token}`) + .field('documentType', 'passport') + .attach('document', dummyFilePath) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.message).toBe('KYC submission already exists or is already approved'); + }); + + it('rejects request if documentType is missing', async () => { + const mockUser = { + _id: userId, + kycStatus: 'rejected', // User has a rejected KYC, so they should be allowed to submit again + kycSubmissionDate: new Date(), + kycReviewNotes: 'invalid document', + save: jest.fn().mockResolvedValue(true), + }; + User.findById.mockResolvedValue(mockUser); + + const response = await request(app) + .post('/api/users/me/kyc') + .set('Authorization', `Bearer ${token}`) + .attach('document', dummyFilePath) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.message).toContain('Document type is required'); + }); + + it('rejects request if documentType is invalid', async () => { + const mockUser = { + _id: userId, + kycStatus: 'rejected', + kycSubmissionDate: new Date(), + kycReviewNotes: 'invalid document', + save: jest.fn().mockResolvedValue(true), + }; + User.findById.mockResolvedValue(mockUser); + + const response = await request(app) + .post('/api/users/me/kyc') + .set('Authorization', `Bearer ${token}`) + .field('documentType', 'invalid_type') + .attach('document', dummyFilePath) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.message).toContain('Document type must be one of: passport, national_id, driving_license'); + }); + + it('rejects request if no file is uploaded', async () => { + const mockUser = { + _id: userId, + kycStatus: 'rejected', + kycSubmissionDate: new Date(), + kycReviewNotes: 'invalid document', + save: jest.fn().mockResolvedValue(true), + }; + User.findById.mockResolvedValue(mockUser); + + const response = await request(app) + .post('/api/users/me/kyc') + .set('Authorization', `Bearer ${token}`) + .field('documentType', 'passport') + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.message).toBe('KYC document file is required'); + }); + + it('submits KYC successfully and updates user status to pending', async () => { + const mockUser = { + _id: userId, + kycStatus: 'rejected', + kycSubmissionDate: null, + kycReviewNotes: 'rejected before', + save: jest.fn().mockImplementation(function() { + return Promise.resolve(this); + }), + }; + User.findById.mockResolvedValue(mockUser); + + const mockKyc = { + _id: '607f1f77bcf86cd799439012', + userId, + documentType: 'passport', + documentUrl: 'uploads/kyc/kyc-507f1f77bcf86cd799439011-mocked.png', + status: 'pending', + submittedAt: new Date('2026-06-26T01:00:00.000Z'), + }; + KYC.create.mockResolvedValue(mockKyc); + + const response = await request(app) + .post('/api/users/me/kyc') + .set('Authorization', `Bearer ${token}`) + .field('documentType', 'passport') + .attach('document', dummyFilePath) + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.message).toBe('KYC document submitted successfully'); + expect(response.body.data.documentType).toBe('passport'); + expect(response.body.data.status).toBe('pending'); + + expect(User.findById).toHaveBeenCalledWith(userId); + expect(KYC.create).toHaveBeenCalled(); + expect(mockUser.kycStatus).toBe('pending'); + expect(mockUser.kycReviewNotes).toBeNull(); + expect(mockUser.save).toHaveBeenCalledTimes(1); + }); +});