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 |
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.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.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}`) |