Task CRUD API — Service Layer, Caching, Permissions, and Soft Delete

The Task CRUD API is the core of the Task Manager — the endpoints that create, read, update, delete, and search tasks with the full business logic of workspaces, permissions, soft delete, caching, and change stream–driven real-time updates. Every design decision from Chapters 4–18 converges here: the Mongoose model enforces schema rules, Zod validates input, the auth middleware verifies identity, the workspace middleware checks permissions, Redis caches list results, and change stream events broadcast updates to all connected clients.

Task API Endpoint Design

Endpoint Auth Permission Cache
GET /tasks?workspaceId=&page= Required Workspace member 30s per user+workspace+page+filters
GET /tasks/:id Required Workspace member 60s per task ID
POST /tasks Required Workspace member Invalidate user’s list cache
PATCH /tasks/:id Required Member (own) or Admin Update task cache, invalidate lists
DELETE /tasks/:id Required Member (own) or Admin Delete task cache, invalidate lists
POST /tasks/:id/restore Required Admin only Restore soft-deleted task
POST /tasks/:id/assign Required Member or Admin Update task cache
GET /tasks/search?q= Required Workspace member No cache — search results change frequently
Note: The soft delete pattern (deletedAt: new Date() instead of deleteOne()) is used throughout the Task Manager. All queries that list tasks filter with { deletedAt: { $exists: false } } and the partial index makes this filter fast. Soft-deleted tasks appear in a “Trash” view, remain recoverable for 90 days (TTL index), and appear in audit logs. Hard deletion via the TTL index happens automatically — no background job needed.
Tip: Use asyncHandler from express-async-errors or a custom wrapper rather than repeating try/catch in every controller. export const getAll = asyncHandler(async (req, res) => { ... }) ensures any thrown error (including unhandled rejections from await) is forwarded to the next error-handling middleware automatically. Without this wrapper, an uncaught async error in a route handler bypasses Express error middleware and produces an unhandled rejection warning instead of a clean error response.
Warning: Scope all task queries to the workspace. Task.findById(req.params.id) without a workspace filter would let a user from Workspace A access tasks from Workspace B if they know the task ID. Always include { _id: req.params.id, workspace: req.workspace._id } in the query — the workspace filter is the horizontal access control fence. The workspace ID comes from the route middleware that verified the user is a member, not from the request body (which could be forged).

Complete Task Service and Controller

// ── apps/api/src/modules/tasks/task.service.js ───────────────────────────
const mongoose = require('mongoose');
const Task     = require('./task.model');
const cache    = require('../../services/cache.service');
const { NotFoundError, AuthorizationError } = require('../../errors/app-errors');

const TASK_TTL = 60;
const LIST_TTL = 30;

function taskKey(id)                    { return `task:${id}`; }
function listKey(userId, wsId, params)  { return `tasks:${userId}:${wsId}:${JSON.stringify(params)}`; }
function wsTag(wsId)                    { return `ws:${wsId}`; }

exports.getAll = async (workspaceId, userId, params = {}) => {
    const { page = 1, limit = 20, status, priority, assignee, q, sort = '-createdAt' } = params;
    const key = listKey(userId, workspaceId, params);

    return cache.getOrSet(key, async () => {
        const filter = {
            workspace: new mongoose.Types.ObjectId(workspaceId),
            deletedAt: { $exists: false },
        };
        if (status)   filter.status   = status;
        if (priority) filter.priority = priority;
        if (assignee) filter.assignees = new mongoose.Types.ObjectId(assignee);
        if (q)        filter.$text    = { $search: q };

        const skip = (page - 1) * limit;
        const [tasks, total] = await Promise.all([
            Task.find(filter).sort(sort).skip(skip).limit(limit)
                .populate('assignees', 'name email avatarUrl')
                .populate('createdBy', 'name email')
                .lean(),
            Task.countDocuments(filter),
        ]);
        return { tasks, total, page, limit, totalPages: Math.ceil(total / limit) };
    }, LIST_TTL);
};

exports.getById = async (taskId, workspaceId) => {
    return cache.getOrSet(taskKey(taskId), () =>
        Task.findOne({ _id: taskId, workspace: workspaceId, deletedAt: { $exists: false } })
            .populate('assignees', 'name email avatarUrl')
            .populate('createdBy', 'name email')
            .lean()
    , TASK_TTL);
};

exports.create = async (dto, workspaceId, userId) => {
    const task = await Task.create({
        ...dto,
        workspace: workspaceId,
        createdBy: userId,
        dueDate:   dto.dueDate ? new Date(dto.dueDate) : undefined,
    });

    // Invalidate all cached lists for this workspace
    await cache.invalidateTag(wsTag(workspaceId));
    return task;
};

exports.update = async (taskId, workspaceId, dto, requestingUserId, requestingRole) => {
    const existing = await Task.findOne({ _id: taskId, workspace: workspaceId, deletedAt: { $exists: false } });
    if (!existing) throw new NotFoundError('Task', taskId);

    // Only task creator or workspace admin can update
    const isCreator = existing.createdBy.toString() === requestingUserId;
    const isAdmin   = ['admin', 'owner'].includes(requestingRole);
    if (!isCreator && !isAdmin) throw new AuthorizationError('Only the task creator or workspace admin can edit this task');

    const updated = await Task.findByIdAndUpdate(
        taskId,
        { $set: dto },
        { new: true, runValidators: true }
    ).lean();

    // Update cache
    await cache.set(taskKey(taskId), updated, TASK_TTL);
    await cache.invalidateTag(wsTag(workspaceId));

    return updated;
};

exports.softDelete = async (taskId, workspaceId, userId, userRole) => {
    const task = await Task.findOne({ _id: taskId, workspace: workspaceId, deletedAt: { $exists: false } });
    if (!task) throw new NotFoundError('Task', taskId);

    const isCreator = task.createdBy.toString() === userId;
    const isAdmin   = ['admin', 'owner'].includes(userRole);
    if (!isCreator && !isAdmin) throw new AuthorizationError('Cannot delete this task');

    task.deletedAt = new Date();
    task.deletedBy = userId;
    await task.save();

    await cache.del(taskKey(taskId));
    await cache.invalidateTag(wsTag(workspaceId));

    return task;
};

exports.assignUser = async (taskId, workspaceId, assigneeId) => {
    const task = await Task.findOneAndUpdate(
        { _id: taskId, workspace: workspaceId, deletedAt: { $exists: false } },
        { $addToSet: { assignees: assigneeId } },   // $addToSet prevents duplicates
        { new: true, runValidators: true }
    );
    if (!task) throw new NotFoundError('Task', taskId);
    await cache.set(taskKey(taskId), task.toObject(), TASK_TTL);
    await cache.invalidateTag(wsTag(workspaceId));
    return task;
};

// ── Task controller ────────────────────────────────────────────────────────
// apps/api/src/modules/tasks/task.controller.js
const asyncHandler = require('express-async-handler');
const taskService  = require('./task.service');
const { success, created, paginated } = require('../../utils/response');

exports.getAll = asyncHandler(async (req, res) => {
    const { workspaceId } = req.query;
    const result = await taskService.getAll(workspaceId, req.user.sub, req.query);
    paginated(res, result.tasks, result.total, result.page, result.limit);
});

exports.getById = asyncHandler(async (req, res) => {
    const task = await taskService.getById(req.params.id, req.workspace._id);
    if (!task) throw new NotFoundError('Task', req.params.id);
    success(res, task);
});

exports.create = asyncHandler(async (req, res) => {
    const task = await taskService.create(req.body, req.body.workspaceId, req.user.sub);
    created(res, task);
});

exports.update = asyncHandler(async (req, res) => {
    const task = await taskService.update(
        req.params.id, req.workspace._id,
        req.body, req.user.sub, req.workspaceMember.role
    );
    success(res, task);
});

exports.softDelete = asyncHandler(async (req, res) => {
    await taskService.softDelete(
        req.params.id, req.workspace._id,
        req.user.sub, req.workspaceMember.role
    );
    res.status(204).send();
});

How It Works

Step 1 — Workspace Scoping Is the Access Control Fence

Every task query includes workspace: workspaceId where workspaceId comes from req.workspace._id — set by the workspace middleware that verified the authenticated user is a member of that workspace. The workspace ID never comes from req.body or req.query for security-sensitive operations, because the client could forge those values. The middleware-set value is authoritative.

Step 2 — Cache Invalidation Uses Workspace-Level Tags

Each task creation, update, or deletion invalidates the workspace tag (ws:workspaceId), which deletes all cached list results for that workspace. Individual task caches are updated (not deleted) on update — the new value is cached immediately so the next read is a cache hit with fresh data. On deletion, the individual cache entry is deleted. This cache invalidation strategy ensures consistency while maximising cache hit rates.

Step 3 — $addToSet Prevents Duplicate Assignees

Using { $addToSet: { assignees: assigneeId } } in the assign operation is a MongoDB atomic operation that adds the ID to the array only if it is not already present. This prevents duplicate assignees without requiring a read-then-write pattern (which has a race condition window). The alternative $push would add duplicates; a read-then-check-then-push pattern would require a transaction to be race-condition-safe.

Step 4 — Promise.all Parallelises Data and Count Queries

The getAll service method runs Task.find() (the paginated data) and Task.countDocuments() (the total for pagination controls) in parallel with Promise.all. Both queries use the same filter object and both can use the same partial index. Running them sequentially would add both query times; running them in parallel takes the time of the slower one. For a result set of 20 tasks out of 10,000, both are fast — but parallelism eliminates the unnecessary sequential wait.

Step 5 — asyncHandler Eliminates try/catch Boilerplate

Without asyncHandler, every async controller method needs a try { ... } catch(err) { next(err) } wrapper. With it, any thrown error (including from await calls deep inside service methods) is automatically forwarded to the Express error middleware. The service throws typed errors (NotFoundError, AuthorizationError) and the global error handler converts them to the correct HTTP responses — a clean separation of concerns.

Quick Reference

Task Code
Scope to workspace Task.findOne({ _id: id, workspace: req.workspace._id })
Soft delete task.deletedAt = new Date(); await task.save()
Prevent duplicate assignee { $addToSet: { assignees: userId } }
Parallel count + data await Promise.all([Task.find(f).lean(), Task.countDocuments(f)])
Run validators on update findByIdAndUpdate(id, update, { new: true, runValidators: true })
Populate references .populate('assignees', 'name email avatarUrl')
Async handler exports.create = asyncHandler(async (req, res) => { ... })
Workspace tag invalidation await cache.invalidateTag(`ws:${workspaceId}`)

🧠 Test Yourself

A member of Workspace A knows the MongoDB ObjectId of a task in Workspace B. They make a PATCH request to /api/v1/tasks/:id with Workspace B’s task ID. Why does the request fail even though the task ID is valid?