Full-Stack Task Manager Project in Node.js

Full-Stack Task Manager Project
This module brings together everything from the course into one cohesive project: a Task Manager with workspaces, assignments, file attachments, and role-based access. Building it will solidify your understanding of how the individual pieces — auth, routing, caching, validation, file handling — work together in a real application.
This is Module 24 of the Node.js Full‑Stack Developer course.
What We Are Building
Features:
- User registration and JWT login (with Google OAuth)
- Workspaces (projects) with member roles (owner, admin, member)
- Tasks with title, description, priority, due date, status, and assignee
- File attachments on tasks (upload, download, delete)
- Redis caching for workspace and task lists
- Role-based access: only workspace members can see its tasks
Tech stack:
- Backend: Express + MongoDB (Mongoose) + Redis (ioredis)
- Auth: JWT access/refresh tokens + bcrypt
- File uploads: Multer (disk storage)
- Frontend: React + React Router (served from Express)
Project Structure
task-manager/
├── client/
│ ├── src/
│ │ ├── App.jsx
│ │ ├── lib/api.js
│ │ ├── hooks/useAuth.js
│ │ ├── context/AuthContext.jsx
│ │ └── pages/
│ │ ├── LoginPage.jsx
│ │ ├── RegisterPage.jsx
│ │ ├── DashboardPage.jsx
│ │ ├── WorkspacePage.jsx
│ │ └── TaskDetailPage.jsx
│ └── vite.config.js
├── src/
│ ├── app.js
│ ├── server.js
│ ├── config/
│ │ ├── db.js
│ │ └── passport.js
│ ├── features/
│ │ ├── auth/
│ │ ├── users/
│ │ ├── workspaces/
│ │ └── tasks/
│ ├── middleware/
│ │ ├── auth.js
│ │ ├── rbac.js
│ │ ├── validate.js
│ │ ├── upload.js
│ │ └── errorHandler.js
│ └── lib/
│ ├── tokens.js
│ ├── redis.js
│ ├── cache.js
│ └── errors.js
├── uploads/ ← task file attachments
├── .env
└── package.jsonData Models
User
// features/users/users.model.js
import mongoose from 'mongoose';
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true, lowercase: true },
password: { type: String, select: false },
avatar: String,
tokenVersion: { type: Number, default: 0 },
}, { timestamps: true });
export const User = mongoose.model('User', userSchema);Workspace
// features/workspaces/workspace.model.js
import mongoose from 'mongoose';
const memberSchema = new mongoose.Schema({
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
role: { type: String, enum: ['owner', 'admin', 'member'], default: 'member' },
}, { _id: false });
const workspaceSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true },
description: String,
color: { type: String, default: '#6366f1' },
members: [memberSchema],
}, { timestamps: true });
// Index for fast member lookup
workspaceSchema.index({ 'members.userId': 1 });
export const Workspace = mongoose.model('Workspace', workspaceSchema);Task
// features/tasks/task.model.js
import mongoose from 'mongoose';
const attachmentSchema = new mongoose.Schema({
filename: String,
originalName: String,
mimetype: String,
size: Number,
path: String,
}, { timestamps: true });
const taskSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true },
description: String,
status: { type: String, enum: ['todo', 'in_progress', 'done'], default: 'todo' },
priority: { type: String, enum: ['low', 'medium', 'high'], default: 'medium' },
dueDate: Date,
workspaceId: { type: mongoose.Schema.Types.ObjectId, ref: 'Workspace', required: true },
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
assignee: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
attachments: [attachmentSchema],
}, { timestamps: true });
taskSchema.index({ workspaceId: 1, status: 1 });
taskSchema.index({ workspaceId: 1, assignee: 1 });
taskSchema.index({ dueDate: 1, status: 1 });
export const Task = mongoose.model('Task', taskSchema);Workspace Membership Middleware
// middleware/workspace.js
import { Workspace } from '../features/workspaces/workspace.model.js';
import { AppError } from '../lib/errors.js';
/**
* Checks that the requesting user is a member of the workspace in req.params.workspaceId.
* Attaches req.workspace and req.membership for downstream use.
*/
export async function requireWorkspaceMember(req, res, next) {
try {
const workspace = await Workspace.findById(req.params.workspaceId);
if (!workspace) return next(new AppError('Workspace not found', 404));
const membership = workspace.members.find(
m => m.userId.toString() === req.user.sub
);
if (!membership) return next(new AppError('Access denied', 403));
req.workspace = workspace;
req.membership = membership;
next();
} catch (err) {
next(err);
}
}
export function requireWorkspaceRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.membership?.role)) {
return next(new AppError('Insufficient workspace role', 403));
}
next();
};
}Tasks Router
// features/tasks/tasks.router.js
import express from 'express';
import * as controller from './tasks.controller.js';
import { requireAuth } from '../../middleware/auth.js';
import { requireWorkspaceMember, requireWorkspaceRole } from '../../middleware/workspace.js';
import { validate } from '../../middleware/validate.js';
import { upload } from '../../middleware/upload.js';
import { createTaskSchema, updateTaskSchema } from './tasks.schema.js';
const router = express.Router({ mergeParams: true }); // inherit :workspaceId
// All task routes require auth + workspace membership
router.use(requireAuth);
router.use(requireWorkspaceMember);
router.get('/', controller.list);
router.post('/', validate(createTaskSchema), controller.create);
router.get('/:id', controller.getById);
router.put('/:id', validate(updateTaskSchema), controller.update);
router.delete('/:id', requireWorkspaceRole('owner', 'admin'), controller.remove);
// File attachments
router.post('/:id/attachments', upload.single('file'), controller.addAttachment);
router.delete('/:id/attachments/:fileId', controller.removeAttachment);
export default router;// app.js
app.use('/api/v1/workspaces/:workspaceId/tasks', tasksRouter);Tasks live under /api/v1/workspaces/:workspaceId/tasks — always scoped to a workspace.
Task Service with Caching
// features/tasks/tasks.service.js
import { Task } from './task.model.js';
import { getOrSet, invalidate, invalidatePattern } from '../../lib/cache.js';
import { AppError } from '../../lib/errors.js';
export async function listTasks(workspaceId, filters = {}) {
const { status, priority, assignee, page = 1, limit = 50 } = filters;
const cacheKey = `tasks:ws=${workspaceId}:${JSON.stringify(filters)}`;
return getOrSet(cacheKey, async () => {
const query = { workspaceId, ...status && { status }, ...priority && { priority }, ...assignee && { assignee } };
const [tasks, total] = await Promise.all([
Task.find(query)
.populate('createdBy', 'name avatar')
.populate('assignee', 'name avatar')
.sort({ createdAt: -1 })
.skip((page - 1) * limit)
.limit(limit)
.lean(),
Task.countDocuments(query),
]);
return { data: tasks, meta: { total, page, limit } };
}, 30); // 30 second TTL for task lists
}
export async function createTask(data) {
const task = await Task.create(data);
await invalidatePattern(`tasks:ws=${data.workspaceId}:*`);
return task;
}
export async function updateTask(id, workspaceId, data) {
const task = await Task.findOneAndUpdate(
{ _id: id, workspaceId },
data,
{ new: true, runValidators: true }
).populate('assignee', 'name avatar');
if (!task) throw new AppError('Task not found', 404);
await Promise.all([
invalidate(`tasks:single:${id}`),
invalidatePattern(`tasks:ws=${workspaceId}:*`),
]);
return task;
}
export async function deleteTask(id, workspaceId) {
const task = await Task.findOneAndDelete({ _id: id, workspaceId });
if (!task) throw new AppError('Task not found', 404);
await Promise.all([
invalidate(`tasks:single:${id}`),
invalidatePattern(`tasks:ws=${workspaceId}:*`),
]);
return task;
}File Upload Middleware
// middleware/upload.js
import multer from 'multer';
import path from 'path';
import { AppError } from '../lib/errors.js';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'text/plain'];
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
const storage = multer.diskStorage({
destination: 'uploads/',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
const name = `${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`;
cb(null, name);
},
});
export const upload = multer({
storage,
limits: { fileSize: MAX_SIZE },
fileFilter(req, file, cb) {
if (!ALLOWED_TYPES.includes(file.mimetype)) {
return cb(new AppError(`File type ${file.mimetype} is not allowed`, 400));
}
cb(null, true);
},
});React: Auth Context
// client/src/context/AuthContext.jsx
import { createContext, useContext, useState, useCallback } from 'react';
import { api, setAccessToken, clearAccessToken } from '../lib/api';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = useCallback(async (email, password) => {
const res = await api.post('/auth/login', { email, password });
const data = await res.json();
if (!res.ok) throw new Error(data.message);
setAccessToken(data.accessToken);
setUser(data.user);
}, []);
const logout = useCallback(async () => {
await api.post('/auth/logout');
clearAccessToken();
setUser(null);
}, []);
const refreshAuth = useCallback(async () => {
const res = await fetch('/api/v1/auth/refresh', { method: 'POST', credentials: 'include' });
if (res.ok) {
const { accessToken, user } = await res.json();
setAccessToken(accessToken);
setUser(user);
return true;
}
return false;
}, []);
return (
<AuthContext.Provider value={{ user, login, logout, refreshAuth }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);React: Workspace Dashboard
// client/src/pages/DashboardPage.jsx
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../lib/api';
export function DashboardPage() {
const [workspaces, setWorkspaces] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get('/workspaces')
.then(r => r.json())
.then(data => setWorkspaces(data.workspaces))
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading workspaces...</p>;
return (
<div>
<h1>My Workspaces</h1>
<div className="grid">
{workspaces.map(ws => (
<Link key={ws._id} to={`/workspaces/${ws._id}`}>
<div style={{ borderLeft: `4px solid ${ws.color}` }}>
<h2>{ws.name}</h2>
<p>{ws.description}</p>
<span>{ws.members.length} members</span>
</div>
</Link>
))}
</div>
<button onClick={() => {/* open create modal */}}>
+ New Workspace
</button>
</div>
);
}Running the Project
# Install dependencies
npm install
cd client && npm install && cd ..
# Start MongoDB and Redis
docker compose up -d
# Development (API + React dev server)
npm run dev
# Production build
npm run build # builds React into /dist
npm start # serves API + React from Express on port 3000// package.json scripts
{
"scripts": {
"dev": "concurrently \"nodemon src/server.js\" \"cd client && npm run dev\"",
"build": "cd client && npm run build",
"start": "node src/server.js"
}
}Node.js Full‑Stack Course — Module 24 of 32
You have built a production-ready full-stack application. Continue to Module 25 to add real-time features with WebSockets.
Summary
The Task Manager project demonstrates how the course concepts integrate:
- Feature-based structure (Module 13) keeps workspaces, tasks, and auth self-contained
- Mongoose models (Module 14) with embedded membership arrays and indexed queries
- JWT + bcrypt auth (Modules 18–19) protects every endpoint
- RBAC + workspace middleware (Module 20) scopes all task operations to workspace members
- Redis caching (Module 17) serves task lists in under 1ms on repeat requests
- Multer file uploads (Module 12) handle task attachments with type and size validation
- React served from Express (Module 22) gives a unified single-domain deployment
- Auth context + in-memory token (Module 22) keeps the frontend secure and stateless
Continue to Module 25: Real-Time Features with WebSockets →
