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
71 changes: 71 additions & 0 deletions src/__tests__/project.routes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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' } },
],
});
});
});
130 changes: 95 additions & 35 deletions src/controllers/project.controller.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,49 @@
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.
*/
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;
Expand All @@ -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 !== 'inactive' && 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, { project: 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);
}
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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,
};
1 change: 1 addition & 0 deletions src/models/Project.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
Expand Down
17 changes: 9 additions & 8 deletions src/routes/project.routes.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading