Node.jsBackendFull-Stack

Full-Stack Task Manager Project in Node.js

TT
TopicTrick Team
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

text
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.json

Data Models

User

js
// 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

js
// 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

js
// 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

js
// 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

js
// 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;
js
// 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

js
// 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

js
// 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

jsx
// 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

jsx
// 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

bash
# 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
json
// 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 →