From 3b753624c04a3c25cd683fa6aa804a3120aeb34a Mon Sep 17 00:00:00 2001 From: Nafiu Ishaq <63783380+nafiuishaaq@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:24:35 +0000 Subject: [PATCH 1/2] implemented the get all projects endpoints --- src/__tests__/project.routes.test.js | 71 ++++++++++++++ src/controllers/project.controller.js | 130 +++++++++++++++++++------- src/models/Project.model.js | 1 + src/routes/project.routes.js | 17 ++-- 4 files changed, 176 insertions(+), 43 deletions(-) diff --git a/src/__tests__/project.routes.test.js b/src/__tests__/project.routes.test.js index 48a3f2c..7f657cf 100644 --- a/src/__tests__/project.routes.test.js +++ b/src/__tests__/project.routes.test.js @@ -3,6 +3,8 @@ const request = require('supertest'); jest.mock('../models/Project.model', () => ({ findById: jest.fn(), + find: jest.fn(), + countDocuments: jest.fn(), })); jest.mock('../models/User.model', () => ({ @@ -144,3 +146,72 @@ describe('GET /api/projects/:id', () => { expect(response.body.data.project.owner).toEqual({ fullName: 'Owner Name' }); }); }); + +describe('GET /api/projects', () => { + it('returns active public projects with pagination metadata', async () => { + const projectList = [ + { + _id: '507f1f77bcf86cd799439066', + title: 'Campaign A', + description: 'Active campaign A', + status: 'active', + isActive: true, + createdAt: new Date('2026-06-22T12:00:00Z'), + }, + { + _id: '507f1f77bcf86cd799439067', + title: 'Campaign B', + description: 'Active campaign B', + status: 'active', + isActive: true, + createdAt: new Date('2026-06-21T12:00:00Z'), + }, + ]; + + const chain = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(projectList), + }; + + Project.find.mockReturnValue(chain); + Project.countDocuments.mockResolvedValue(2); + + const response = await request(app).get('/api/projects?page=1&limit=2').expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.data).toHaveLength(2); + expect(response.body.data.total).toBe(2); + expect(response.body.data.page).toBe(1); + expect(response.body.data.limit).toBe(2); + expect(response.body.data.totalPages).toBe(1); + expect(Project.find).toHaveBeenCalledWith({ status: 'active', isActive: true }); + }); + + it('supports search and category filtering', async () => { + const chain = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([]), + }; + + Project.find.mockReturnValue(chain); + Project.countDocuments.mockResolvedValue(0); + + await request(app) + .get('/api/projects?search=water&category=Energy&page=2&limit=5') + .expect(200); + + expect(Project.find).toHaveBeenCalledWith({ + status: 'active', + isActive: true, + category: 'Energy', + $or: [ + { title: { $regex: 'water', $options: 'i' } }, + { description: { $regex: 'water', $options: 'i' } }, + ], + }); + }); +}); diff --git a/src/controllers/project.controller.js b/src/controllers/project.controller.js index 9c54fd7..98e0c81 100644 --- a/src/controllers/project.controller.js +++ b/src/controllers/project.controller.js @@ -1,16 +1,41 @@ const Project = require('../models/Project.model'); const { sendSuccess } = require('../utils/response'); +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const parsePositiveInteger = (value, fallback) => { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +}; + /** * Create a new project/campaign * POST /api/projects */ const createProject = async (req, res, next) => { try { - if (req.user.kycStatus !== 'approved') { + if (req.user?.kycStatus !== 'approved') { const error = new Error('KYC approval is required to create a project'); error.statusCode = 403; - + return next(error); + } + + const { title, description } = req.body; + + const project = await Project.create({ + title, + description, + owner: req.userId, + status: 'pending', + }); + + return sendSuccess(res, project, 201, 'Project created successfully'); + } catch (error) { + return next(error); + } +}; + +/** * GET /api/projects/:id * Retrieve campaign details for a single project. */ @@ -18,7 +43,7 @@ const getProjectDetails = async (req, res, next) => { try { const { id } = req.params; - const project = await Project.findById(id).populate('owner', 'fullName'); + const project = await Project.findById(id).populate('owner', 'fullName').exec(); if (!project) { const error = new Error('Project not found'); error.statusCode = 404; @@ -29,39 +54,73 @@ const getProjectDetails = async (req, res, next) => { const ownerId = project.owner && project.owner._id ? project.owner._id.toString() : project.owner?.toString(); const isOwner = req.userId && ownerId === req.userId; const isAdmin = req.user?.role === 'admin'; + const isPublic = project.status === 'active' && project.isActive !== false; - if (project.isActive === false && !isOwner && !isAdmin) { - const isActive = project.status !== 'inactive'; - const isOwner = req.userId && project.owner && project.owner._id?.toString() === req.userId; - const isAdmin = req.user && req.user.role === 'admin'; - - if (!isActive && !isOwner && !isAdmin) { + if (!isPublic && !isOwner && !isAdmin) { const error = new Error('Project not found'); error.statusCode = 404; error.isOperational = true; return next(error); - - const { title, description } = req.body; + } - const project = await Project.create({ - title, - description, - owner: req.userId, - status: 'pending', - }); + const projectData = project.toObject(); + projectData.owner = project.owner ? { fullName: project.owner.fullName } : null; - return sendSuccess(res, project, 201, 'Project created successfully'); + return sendSuccess(res, projectData, 200, 'Project retrieved successfully'); + } catch (error) { + return next(error); + } +}; + +/** + * GET /api/projects + * List public active projects + */ +const listProjects = async (req, res, next) => { + try { + const page = parsePositiveInteger(req.query.page, 1); + const limit = parsePositiveInteger(req.query.limit, 10); + const { category, search } = req.query; - const responseProject = project.toObject(); - if (responseProject.owner && responseProject.owner.fullName) { - responseProject.owner = { fullName: responseProject.owner.fullName }; + const query = { + status: 'active', + isActive: true, + }; + + if (category && category.trim()) { + query.category = category.trim(); } - return sendSuccess(res, { project: responseProject }, 200, 'Project retrieved successfully'); - const projectData = project.toObject({ getters: true }); - projectData.owner = project.owner ? { fullName: project.owner.fullName } : null; + if (search && search.trim()) { + const searchRegex = escapeRegExp(search.trim()); + query.$or = [ + { title: { $regex: searchRegex, $options: 'i' } }, + { description: { $regex: searchRegex, $options: 'i' } }, + ]; + } + + const projectsQuery = Project.find(query) + .sort({ createdAt: -1 }) + .skip((page - 1) * limit) + .limit(limit); + + const [projects, total] = await Promise.all([ + projectsQuery.exec(), + Project.countDocuments(query), + ]); - return sendSuccess(res, projectData, 200, 'Project details retrieved successfully'); + return sendSuccess( + res, + { + data: projects, + total, + page, + limit, + totalPages: Math.max(1, Math.ceil(total / limit)), + }, + 200, + 'Projects retrieved successfully' + ); } catch (error) { return next(error); } @@ -83,7 +142,6 @@ const uploadDocuments = async (req, res, next) => { return next(error); } - // Ownership check — req.userId is set by your authenticate middleware if (project.owner.toString() !== req.userId) { const error = new Error('Forbidden: you do not own this project'); error.statusCode = 403; @@ -98,7 +156,6 @@ const uploadDocuments = async (req, res, next) => { return next(error); } - // Enforce total document cap (existing + new ≤ 5) const MAX_TOTAL_DOCS = 5; if (project.documents.length + req.files.length > MAX_TOTAL_DOCS) { const slotsLeft = MAX_TOTAL_DOCS - project.documents.length; @@ -112,13 +169,12 @@ const uploadDocuments = async (req, res, next) => { } const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000'; - const newDocs = req.files.map((file) => ({ originalName: file.originalname, - filename: file.filename, - mimetype: file.mimetype, - size: file.size, - url: `${baseUrl}/uploads/documents/${file.filename}`, + filename: file.filename, + mimetype: file.mimetype, + size: file.size, + url: `${baseUrl}/uploads/documents/${file.filename}`, })); project.documents.push(...newDocs); @@ -133,7 +189,11 @@ const uploadDocuments = async (req, res, next) => { } catch (error) { return next(error); } +}; -module.exports = { createProject, uploadDocuments }; -module.exports = { getProjectById, uploadDocuments }; -module.exports = { getProjectDetails, uploadDocuments }; +module.exports = { + createProject, + getProjectDetails, + listProjects, + uploadDocuments, +}; diff --git a/src/models/Project.model.js b/src/models/Project.model.js index 6962112..c3dcd71 100644 --- a/src/models/Project.model.js +++ b/src/models/Project.model.js @@ -16,6 +16,7 @@ const projectSchema = new mongoose.Schema( { title: { type: String, required: true, trim: true }, description: { type: String, trim: true }, + category: { type: String, trim: true, default: null }, owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, isActive: { type: Boolean, default: true }, documents: { type: [documentSchema], default: [] }, diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 6d51276..117ea9d 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -1,18 +1,19 @@ const express = require('express'); const router = express.Router(); const authenticate = require('../middlewares/auth'); -const { optionalAuthenticate } = require('../middlewares/auth'); +const authenticateOptional = require('../middlewares/auth.optional'); const { handleUpload } = require('../middlewares/upload.middleware'); -const { getProjectById, uploadDocuments } = require('../controllers/project.controller'); +const { + getProjectDetails, + listProjects, + uploadDocuments, +} = require('../controllers/project.controller'); /** - * GET /api/projects/:id - * Retrieve a single project by id + * GET /api/projects + * List public active campaigns */ -router.get('/:id', optionalAuthenticate, getProjectById); -const authenticateOptional = require('../middlewares/auth.optional'); -const { handleUpload } = require('../middlewares/upload.middleware'); -const { uploadDocuments, getProjectDetails } = require('../controllers/project.controller'); +router.get('/', listProjects); /** * GET /api/projects/:id From a465b7669a5a39f3bc5e084dfd3da5425fa1f366 Mon Sep 17 00:00:00 2001 From: Nafiu Ishaq <63783380+nafiuishaaq@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:25:35 +0000 Subject: [PATCH 2/2] implemented the get all projects endpoints --- src/controllers/project.controller.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/project.controller.js b/src/controllers/project.controller.js index 98e0c81..9d84dae 100644 --- a/src/controllers/project.controller.js +++ b/src/controllers/project.controller.js @@ -54,7 +54,7 @@ const getProjectDetails = async (req, res, next) => { const ownerId = project.owner && project.owner._id ? project.owner._id.toString() : project.owner?.toString(); const isOwner = req.userId && ownerId === req.userId; const isAdmin = req.user?.role === 'admin'; - const isPublic = project.status === 'active' && project.isActive !== false; + const isPublic = project.status !== 'inactive' && project.isActive !== false; if (!isPublic && !isOwner && !isAdmin) { const error = new Error('Project not found'); @@ -66,7 +66,7 @@ const getProjectDetails = async (req, res, next) => { const projectData = project.toObject(); projectData.owner = project.owner ? { fullName: project.owner.fullName } : null; - return sendSuccess(res, projectData, 200, 'Project retrieved successfully'); + return sendSuccess(res, { project: projectData }, 200, 'Project retrieved successfully'); } catch (error) { return next(error); }