Skip to content
Open
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
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,32 @@
# Shipyard-Interns
Tasks submission and template respository.
Tasks submission and template repository.

## Task: Project Management CRUD Application

Full-stack project management app built with **Next.js 14** (frontend) and **Express.js** (backend).

### Quick Start

**Backend:**
```bash
cd backend
npm install
echo "PORT=5000" > .env
npm run dev
```

**Frontend:**
```bash
cd frontend
npm install
echo "NEXT_PUBLIC_API_URL=http://localhost:5000/api" > .env.local
npm run dev
```

**Tests:**
```bash
cd backend
npm test
```

See [documentation.md](documentation.md) for full details.
24 changes: 24 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "project-management-api",
"version": "1.0.0",
"description": "Express.js REST API for project management",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "jest --runInBand"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0"
},
"devDependencies": {
"jest": "^29.7.0",
"nodemon": "^3.1.4",
"supertest": "^7.0.0"
}
}
18 changes: 18 additions & 0 deletions backend/src/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const express = require('express');
const cors = require('cors');
const projectRoutes = require('./routes/projects');
const errorHandler = require('./middleware/errorHandler');

const app = express();

// Core middleware
app.use(cors());
app.use(express.json());

// Routes
app.use('/api/projects', projectRoutes);

// Global error handler — must be registered last
app.use(errorHandler);

module.exports = app;
121 changes: 121 additions & 0 deletions backend/src/controllers/projectController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
const store = require('../store/inMemoryStore');

const createProject = (req, res, next) => {
try {
const { name, description, ownerId, status } = req.body;
const project = store.create({ name, description, ownerId, status });
res.status(201).json(project);
} catch (error) {
next(error);
}
};

const getAllProjects = (req, res, next) => {
try {
let { page, limit, status, search } = req.query;

page = parseInt(page, 10) || 1;
limit = parseInt(limit, 10) || 10;

// Enforce bounds
if (page < 1) page = 1;
if (limit < 1) limit = 1;
if (limit > 50) limit = 50;

let filteredProjects = store.getAll();

// Filter by status if provided
if (status) {
filteredProjects = filteredProjects.filter(
(project) => project.status === status
);
}

// Search in name and description if provided
if (search) {
const searchLower = search.toLowerCase();
filteredProjects = filteredProjects.filter(
(project) =>
project.name.toLowerCase().includes(searchLower) ||
project.description.toLowerCase().includes(searchLower)
);
}

const total = filteredProjects.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const paginatedProjects = filteredProjects.slice(startIndex, startIndex + limit);

res.status(200).json({
data: paginatedProjects,
total,
page,
limit,
totalPages,
});
} catch (error) {
next(error);
}
};

const getProjectById = (req, res, next) => {
try {
const { id } = req.params;
const project = store.getById(id);

if (!project) {
return res.status(404).json({ error: 'Project not found' });
}

res.status(200).json(project);
} catch (error) {
next(error);
}
};

const updateProject = (req, res, next) => {
try {
const { id } = req.params;
const existingProject = store.getById(id);

if (!existingProject) {
return res.status(404).json({ error: 'Project not found' });
}

const { name, description, ownerId, status } = req.body;
const updateData = {};

if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (ownerId !== undefined) updateData.ownerId = ownerId;
if (status !== undefined) updateData.status = status;

const updatedProject = store.update(id, updateData);
res.status(200).json(updatedProject);
} catch (error) {
next(error);
}
};

const deleteProject = (req, res, next) => {
try {
const { id } = req.params;
const deleted = store.remove(id);

if (!deleted) {
return res.status(404).json({ error: 'Project not found' });
}

res.status(200).json({ message: 'Project deleted successfully' });
} catch (error) {
next(error);
}
};

module.exports = {
createProject,
getAllProjects,
getProjectById,
updateProject,
deleteProject,
};
9 changes: 9 additions & 0 deletions backend/src/middleware/errorHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Global error handler — must be the last middleware registered
const errorHandler = (err, req, res, _next) => {
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';

res.status(statusCode).json({ error: message });
};

module.exports = errorHandler;
103 changes: 103 additions & 0 deletions backend/src/middleware/validation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
const VALID_STATUSES = ['active', 'inactive', 'completed'];

const validateName = (name, isRequired = true) => {
if (isRequired && (name === undefined || name === null)) {
return 'name is required';
}
if (name !== undefined && name !== null) {
if (typeof name !== 'string' || name.trim().length < 3) {
return 'name must be at least 3 characters';
}
if (name.trim().length > 100) {
return 'name must be at most 100 characters';
}
}
return null;
};

const validateDescription = (description, isRequired = true) => {
if (isRequired && (description === undefined || description === null)) {
return 'description is required';
}
if (description !== undefined && description !== null) {
if (typeof description !== 'string' || description.trim().length < 10) {
return 'description must be at least 10 characters';
}
if (description.trim().length > 500) {
return 'description must be at most 500 characters';
}
}
return null;
};

const validateOwnerId = (ownerId, isRequired = true) => {
if (isRequired && (ownerId === undefined || ownerId === null)) {
return 'ownerId is required';
}
if (ownerId !== undefined && ownerId !== null) {
if (typeof ownerId !== 'string' || ownerId.trim().length === 0) {
return 'ownerId must be a non-empty string';
}
}
return null;
};

const validateStatus = (status, isRequired = true) => {
if (isRequired && (status === undefined || status === null)) {
return 'status is required';
}
if (status !== undefined && status !== null) {
if (!VALID_STATUSES.includes(status)) {
return 'status must be one of: active, inactive, completed';
}
}
return null;
};

const validateCreateProject = (req, res, next) => {
const { name, description, ownerId, status } = req.body;
const errors = [];

const nameError = validateName(name, true);
if (nameError) errors.push(nameError);

const descriptionError = validateDescription(description, true);
if (descriptionError) errors.push(descriptionError);

const ownerIdError = validateOwnerId(ownerId, true);
if (ownerIdError) errors.push(ownerIdError);

const statusError = validateStatus(status, true);
if (statusError) errors.push(statusError);

if (errors.length > 0) {
return res.status(400).json({ errors });
}

next();
};

const validateUpdateProject = (req, res, next) => {
const { name, description, ownerId, status } = req.body;
const errors = [];

const nameError = validateName(name, false);
if (nameError) errors.push(nameError);

const descriptionError = validateDescription(description, false);
if (descriptionError) errors.push(descriptionError);

const ownerIdError = validateOwnerId(ownerId, false);
if (ownerIdError) errors.push(ownerIdError);

const statusError = validateStatus(status, false);
if (statusError) errors.push(statusError);

if (errors.length > 0) {
return res.status(400).json({ errors });
}

next();
};

module.exports = { validateCreateProject, validateUpdateProject };
21 changes: 21 additions & 0 deletions backend/src/routes/projects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const express = require('express');
const router = express.Router();
const {
createProject,
getAllProjects,
getProjectById,
updateProject,
deleteProject,
} = require('../controllers/projectController');
const {
validateCreateProject,
validateUpdateProject,
} = require('../middleware/validation');

router.post('/', validateCreateProject, createProject);
router.get('/', getAllProjects);
router.get('/:id', getProjectById);
router.put('/:id', validateUpdateProject, updateProject);
router.delete('/:id', deleteProject);

module.exports = router;
8 changes: 8 additions & 0 deletions backend/src/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
require('dotenv').config();
const app = require('./app');

const PORT = process.env.PORT || 5000;

app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Loading