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
6 changes: 3 additions & 3 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const authRoutes = require('./routes/auth.routes');
const userRoutes = require('./routes/users.routes');
const protectedRoutes = require('./routes/protected.routes');
const adminRoutes = require('./routes/admin.routes');

const projectRoutes = require('./routes/project.routes');
const app = express();

const allowedOrigins =
Expand All @@ -27,7 +27,7 @@ app.use(
// Serve static files from uploads directory
app.use('/uploads', express.static('uploads'));
app.use(helmet());

app.use('/api/projects', projectRoutes);
// rate limiting middleware
app.use(globalLimiter);

Expand All @@ -47,7 +47,7 @@ app.use('/api/protected', protectedRoutes);

// User routes (requires authentication)
app.use('/api/users', userRoutes);

app.use('/api/projects', projectRoutes);
// Admin routes
app.use('/api/admin', adminRoutes);

Expand Down
72 changes: 72 additions & 0 deletions src/controllers/project.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
const Project = require('../models/Project.model');
const { sendSuccess } = require('../utils/response');

/**
* Upload supporting documents to a project
* POST /api/projects/:id/documents
*/
const uploadDocuments = async (req, res, next) => {
try {
const { id } = req.params;

const project = await Project.findById(id);
if (!project) {
const error = new Error('Project not found');
error.statusCode = 404;
error.isOperational = true;
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;
error.isOperational = true;
return next(error);
}

if (!req.files || req.files.length === 0) {
const error = new Error('No files were uploaded');
error.statusCode = 400;
error.isOperational = true;
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;
const error = new Error(
`This project already has ${project.documents.length} document(s). ` +
`You can only add ${slotsLeft} more (max ${MAX_TOTAL_DOCS} total).`
);
error.statusCode = 400;
error.isOperational = true;
return next(error);
}

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}`,
}));

project.documents.push(...newDocs);
await project.save();

return sendSuccess(
res,
{ documents: project.documents },
201,
`${newDocs.length} document(s) uploaded successfully`
);
} catch (error) {
return next(error);
}
};

module.exports = { uploadDocuments };
76 changes: 76 additions & 0 deletions src/middlewares/upload.middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs');

const ALLOWED_MIME_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'image/jpeg',
'image/png',
];

const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_FILES = 5;

// Ensure upload directory exists
const uploadDir = path.join(process.cwd(), 'uploads', 'documents');
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, `${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, DOCX, JPG, and PNG are allowed.'
);
error.statusCode = 400;
error.isOperational = true;
cb(error, false);
}
};

const upload = multer({
storage,
fileFilter,
limits: {
fileSize: MAX_FILE_SIZE,
files: MAX_FILES,
},
});

// Wrap multer to convert its errors into operational errors your errorHandler understands
const handleUpload = (req, res, next) => {
upload.array('documents', MAX_FILES)(req, res, (err) => {
if (!err) return next();

if (err.code === 'LIMIT_FILE_SIZE') {
err.message = 'Each file must be 10MB or smaller.';
err.statusCode = 400;
err.isOperational = true;
} else if (err.code === 'LIMIT_FILE_COUNT') {
err.message = `You may upload at most ${MAX_FILES} files at a time.`;
err.statusCode = 400;
err.isOperational = true;
} else if (err.code === 'LIMIT_UNEXPECTED_FILE') {
err.message = 'Unexpected field name. Use "documents" as the field name.';
err.statusCode = 400;
err.isOperational = true;
}

return next(err);
});
};

module.exports = { handleUpload };
26 changes: 26 additions & 0 deletions src/models/Project.model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const mongoose = require('mongoose');

const documentSchema = new mongoose.Schema(
{
originalName: { type: String, required: true },
filename: { type: String, required: true }, // stored name on disk
mimetype: { type: String, required: true },
size: { type: Number, required: true }, // bytes
url: { type: String, required: true }, // public URL path
uploadedAt: { type: Date, default: Date.now },
},
{ _id: true }
);

const projectSchema = new mongoose.Schema(
{
title: { type: String, required: true, trim: true },
description: { type: String, trim: true },
owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
documents: { type: [documentSchema], default: [] },
// ... add your other project fields here
},
{ timestamps: true }
);

module.exports = mongoose.model('Project', projectSchema);
13 changes: 13 additions & 0 deletions src/routes/project.routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const express = require('express');
const router = express.Router();
const { authenticate } = require('../middlewares/authenticate'); // your existing auth middleware
const { handleUpload } = require('../middlewares/upload.middleware');
const { uploadDocuments } = require('../controllers/project.controller');

/**
* POST /api/projects/:id/documents
* Upload supporting documents to a project (owner only)
*/
router.post('/:id/documents', authenticate, handleUpload, uploadDocuments);

module.exports = router;
Loading