diff --git a/src/app.js b/src/app.js index aac4f45..7004d43 100644 --- a/src/app.js +++ b/src/app.js @@ -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 = @@ -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); @@ -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); diff --git a/src/controllers/project.controller.js b/src/controllers/project.controller.js new file mode 100644 index 0000000..cce47b8 --- /dev/null +++ b/src/controllers/project.controller.js @@ -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 }; \ No newline at end of file diff --git a/src/middlewares/upload.middleware.js b/src/middlewares/upload.middleware.js new file mode 100644 index 0000000..5361518 --- /dev/null +++ b/src/middlewares/upload.middleware.js @@ -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 }; \ No newline at end of file diff --git a/src/models/Project.model.js b/src/models/Project.model.js new file mode 100644 index 0000000..136c53f --- /dev/null +++ b/src/models/Project.model.js @@ -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); \ No newline at end of file diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js new file mode 100644 index 0000000..2a5ae44 --- /dev/null +++ b/src/routes/project.routes.js @@ -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; \ No newline at end of file