Chapter 88 β Full-Stack Feature Walkthrough: Tasks CRUD
π What This Chapter Does
Machine-coding rounds and take-home tasks often say "build a small task manager with login". This chapter builds one feature β Tasks β through every layer, so you can explain (or code) the whole path in an interview:
shared type β Mongoose model β service β controller β routes
β RTK Query endpoints β React componentsRequirements: a logged-in user can list (paginated + filter), create, toggle, edit and delete their own tasks. Admins can see everyone's.
π» Step 1 β Shared Types (packages/shared)
// packages/shared/src/types/task.ts
export const TASK_PRIORITIES = ["low", "medium", "high"] as const;
export type TaskPriority = (typeof TASK_PRIORITIES)[number];
export interface TaskDto {
id: string;
title: string;
description: string;
priority: TaskPriority;
done: boolean;
dueDate: string | null; // ISO string over the wire β Dates don't survive JSON
ownerId: string;
createdAt: string;
updatedAt: string;
}
export type CreateTaskDto = Pick<TaskDto, "title"> & Partial<Pick<TaskDto, "description" | "priority" | "dueDate">>;
export type UpdateTaskDto = Partial<Omit<TaskDto, "id" | "ownerId" | "createdAt" | "updatedAt">>;
export interface TaskQuery {
page?: number;
limit?: number;
status?: "all" | "open" | "done";
priority?: TaskPriority;
search?: string;
}π» Step 2 β Model (apps/server)
// modules/tasks/task.model.ts
import { Schema, model, type Types, type HydratedDocument } from "mongoose";
import { TASK_PRIORITIES, type TaskPriority, type TaskDto } from "@app/shared";
export interface ITask {
title: string;
description: string;
priority: TaskPriority;
done: boolean;
dueDate: Date | null;
owner: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
}
export type TaskDocument = HydratedDocument<ITask>;
const taskSchema = new Schema<ITask>(
{
title: { type: String, required: true, trim: true, maxlength: 200 },
description: { type: String, default: "", maxlength: 5000 },
priority: { type: String, enum: TASK_PRIORITIES, default: "medium" },
done: { type: Boolean, default: false },
dueDate: { type: Date, default: null },
owner: { type: Schema.Types.ObjectId, ref: "User", required: true },
},
{ timestamps: true },
);
// Matches the main list query: my tasks, filtered by done, newest first (ESR rule: Equality, Sort, Range)
taskSchema.index({ owner: 1, done: 1, createdAt: -1 });
taskSchema.index({ title: "text", description: "text" });
export const Task = model<ITask>("Task", taskSchema);
// Model β DTO mapper: the ONLY place that decides what the client sees
export function toTaskDto(t: ITask & { _id: Types.ObjectId }): TaskDto {
return {
id: t._id.toString(),
title: t.title,
description: t.description,
priority: t.priority,
done: t.done,
dueDate: t.dueDate ? t.dueDate.toISOString() : null,
ownerId: t.owner.toString(),
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString(),
};
}π» Step 3 β Service
// modules/tasks/task.service.ts
import type { FilterQuery } from "mongoose";
import type { CreateTaskDto, UpdateTaskDto, TaskQuery, Paginated, TaskDto, Role } from "@app/shared";
import { Task, toTaskDto, type ITask } from "./task.model.js";
import { AppError } from "../../utils/AppError.js";
interface Actor { id: string; role: Role }
// Admins see all; users only their own. Applied to EVERY query β no IDOR bugs.
const scope = (actor: Actor): FilterQuery<ITask> => (actor.role === "admin" ? {} : { owner: actor.id });
export const taskService = {
async list(actor: Actor, q: TaskQuery): Promise<Paginated<TaskDto>> {
const page = Math.max(1, q.page ?? 1);
const limit = Math.min(100, Math.max(1, q.limit ?? 20));
const filter: FilterQuery<ITask> = { ...scope(actor) };
if (q.status === "open") filter.done = false;
if (q.status === "done") filter.done = true;
if (q.priority) filter.priority = q.priority;
if (q.search) filter.$text = { $search: q.search };
const [rows, total] = await Promise.all([
Task.find(filter).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit).lean(),
Task.countDocuments(filter),
]);
return { items: rows.map(toTaskDto), total, page, limit };
},
async create(actor: Actor, dto: CreateTaskDto): Promise<TaskDto> {
const task = await Task.create({ ...dto, owner: actor.id });
return toTaskDto(task.toObject());
},
async update(actor: Actor, id: string, dto: UpdateTaskDto): Promise<TaskDto> {
const task = await Task.findOneAndUpdate({ _id: id, ...scope(actor) }, dto, {
new: true,
runValidators: true,
}).lean();
if (!task) throw AppError.notFound("Task"); // 404 (not 403) β don't reveal it exists
return toTaskDto(task);
},
async remove(actor: Actor, id: string): Promise<void> {
const res = await Task.deleteOne({ _id: id, ...scope(actor) });
if (res.deletedCount === 0) throw AppError.notFound("Task");
},
};π» Step 4 β Validators, Controller, Routes
// modules/tasks/task.validators.ts
import { body, param, query } from "express-validator";
import { TASK_PRIORITIES } from "@app/shared";
export const listRules = [
query("page").optional().isInt({ min: 1 }).toInt(),
query("limit").optional().isInt({ min: 1, max: 100 }).toInt(),
query("status").optional().isIn(["all", "open", "done"]),
query("priority").optional().isIn(TASK_PRIORITIES),
query("search").optional().trim().isLength({ max: 100 }),
];
export const createRules = [
body("title").trim().notEmpty().isLength({ max: 200 }),
body("description").optional().isString().isLength({ max: 5000 }),
body("priority").optional().isIn(TASK_PRIORITIES),
body("dueDate").optional({ values: "null" }).isISO8601(),
];
export const updateRules = [
param("id").isMongoId(),
body("title").optional().trim().notEmpty().isLength({ max: 200 }),
body("description").optional().isString(),
body("priority").optional().isIn(TASK_PRIORITIES),
body("done").optional().isBoolean().toBoolean(),
body("dueDate").optional({ values: "null" }).isISO8601(),
];
export const idRules = [param("id").isMongoId()];// modules/tasks/task.controller.ts
import type { Request, Response } from "express";
import { matchedData } from "express-validator";
import type { ApiResponse, CreateTaskDto, Paginated, TaskDto, TaskQuery, UpdateTaskDto } from "@app/shared";
import { taskService } from "./task.service.js";
// requireAuth runs before these, so req.user is always set
const actor = (req: Request) => req.user!;
export async function list(req: Request, res: Response<ApiResponse<Paginated<TaskDto>>>) {
const q = matchedData(req, { locations: ["query"] }) as TaskQuery;
res.json({ success: true, data: await taskService.list(actor(req), q) });
}
export async function create(req: Request, res: Response<ApiResponse<TaskDto>>) {
const dto = matchedData(req, { locations: ["body"] }) as CreateTaskDto;
res.status(201).json({ success: true, data: await taskService.create(actor(req), dto) });
}
export async function update(req: Request<{ id: string }>, res: Response<ApiResponse<TaskDto>>) {
const dto = matchedData(req, { locations: ["body"] }) as UpdateTaskDto;
res.json({ success: true, data: await taskService.update(actor(req), req.params.id, dto) });
}
export async function remove(req: Request<{ id: string }>, res: Response) {
await taskService.remove(actor(req), req.params.id);
res.status(204).end();
}// modules/tasks/task.routes.ts
import { Router } from "express";
import { requireAuth } from "../../middleware/auth.js";
import { validate } from "../../middleware/validate.js";
import * as v from "./task.validators.js";
import * as c from "./task.controller.js";
export const taskRouter = Router();
taskRouter.use(requireAuth);
taskRouter.get("/", validate(v.listRules), c.list);
taskRouter.post("/", validate(v.createRules), c.create);
taskRouter.patch("/:id", validate(v.updateRules), c.update);
taskRouter.delete("/:id", validate(v.idRules), c.remove);
// app.ts β app.use("/api/tasks", taskRouter);π API Contract
| Method | Path | Body / Query | Response |
|---|---|---|---|
| GET | /api/tasks |
?page&limit&status&priority&search |
200 { success, data: Paginated<TaskDto> } |
| POST | /api/tasks |
CreateTaskDto |
201 { success, data: TaskDto } |
| PATCH | /api/tasks/:id |
UpdateTaskDto |
200 { success, data: TaskDto } |
| DELETE | /api/tasks/:id |
β | 204 |
| any | β | invalid input | 400 { success:false, code:"BAD_REQUEST", details } |
| any | β | not yours / missing | 404 |
π» Step 5 β RTK Query Endpoints (apps/web)
// features/tasks/tasksApi.ts
import { api } from "../../services/api";
import type { ApiResponse, CreateTaskDto, Paginated, TaskDto, TaskQuery, UpdateTaskDto } from "@app/shared";
export const tasksApi = api.injectEndpoints({
endpoints: (b) => ({
getTasks: b.query<Paginated<TaskDto>, TaskQuery>({
query: (params) => ({ url: "/tasks", params }),
transformResponse: (r: ApiResponse<Paginated<TaskDto>>) => r.data,
providesTags: (res) => [
{ type: "Task" as const, id: "LIST" },
...(res?.items.map((t) => ({ type: "Task" as const, id: t.id })) ?? []),
],
}),
createTask: b.mutation<TaskDto, CreateTaskDto>({
query: (body) => ({ url: "/tasks", method: "POST", body }),
transformResponse: (r: ApiResponse<TaskDto>) => r.data,
invalidatesTags: [{ type: "Task", id: "LIST" }],
}),
updateTask: b.mutation<TaskDto, { id: string; query: TaskQuery } & UpdateTaskDto>({
query: ({ id, query: _q, ...patch }) => ({ url: `/tasks/${id}`, method: "PATCH", body: patch }),
transformResponse: (r: ApiResponse<TaskDto>) => r.data,
// Optimistic update of the list the user is looking at
async onQueryStarted({ id, query, ...patch }, { dispatch, queryFulfilled }) {
const undo = dispatch(
tasksApi.util.updateQueryData("getTasks", query, (draft) => {
const t = draft.items.find((x) => x.id === id);
if (t) Object.assign(t, patch);
}),
);
queryFulfilled.catch(undo.undo);
},
invalidatesTags: (_r, _e, { id }) => [{ type: "Task", id }],
}),
deleteTask: b.mutation<void, string>({
query: (id) => ({ url: `/tasks/${id}`, method: "DELETE" }),
invalidatesTags: [{ type: "Task", id: "LIST" }],
}),
}),
});
export const { useGetTasksQuery, useCreateTaskMutation, useUpdateTaskMutation, useDeleteTaskMutation } = tasksApi;π» Step 6 β Client UI State in a Slice
The filter is client state (what the user is looking at), so it lives in a small slice; the tasks themselves are server state in RTK Query.
// features/tasks/taskFiltersSlice.ts
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { TaskQuery } from "@app/shared";
const initialState: Required<Pick<TaskQuery, "page" | "status">> & Pick<TaskQuery, "priority" | "search"> = {
page: 1,
status: "all",
};
const taskFiltersSlice = createSlice({
name: "taskFilters",
initialState,
reducers: {
statusChanged(s, a: PayloadAction<"all" | "open" | "done">) { s.status = a.payload; s.page = 1; },
searchChanged(s, a: PayloadAction<string>) { s.search = a.payload || undefined; s.page = 1; },
pageChanged(s, a: PayloadAction<number>) { s.page = a.payload; },
},
selectors: { selectTaskQuery: (s) => s },
});
export const { statusChanged, searchChanged, pageChanged } = taskFiltersSlice.actions;
export const { selectTaskQuery } = taskFiltersSlice.selectors;
export default taskFiltersSlice.reducer;π» Step 7 β Components
// features/tasks/TasksPage.tsx
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { useGetTasksQuery } from "./tasksApi";
import { selectTaskQuery, statusChanged, pageChanged } from "./taskFiltersSlice";
import { NewTaskForm } from "./NewTaskForm";
import { TaskItem } from "./TaskItem";
export function TasksPage() {
const query = useAppSelector(selectTaskQuery);
const dispatch = useAppDispatch();
const { data, isLoading, isFetching, error } = useGetTasksQuery(query);
const pages = data ? Math.ceil(data.total / data.limit) : 1;
return (
<main>
<h1>My Tasks</h1>
<NewTaskForm />
<div role="tablist">
{(["all", "open", "done"] as const).map((s) => (
<button key={s} role="tab" aria-selected={query.status === s} onClick={() => dispatch(statusChanged(s))}>
{s}
</button>
))}
</div>
{isLoading && <p>Loadingβ¦</p>}
{error && <p role="alert">{errorMessage(error)}</p>}
<ul aria-busy={isFetching}>
{data?.items.map((t) => <TaskItem key={t.id} task={t} query={query} />)}
</ul>
<nav>
<button disabled={query.page <= 1} onClick={() => dispatch(pageChanged(query.page - 1))}>Prev</button>
<span>{query.page} / {pages}</span>
<button disabled={query.page >= pages} onClick={() => dispatch(pageChanged(query.page + 1))}>Next</button>
</nav>
</main>
);
}// features/tasks/TaskItem.tsx
import { memo } from "react";
import type { TaskDto, TaskQuery } from "@app/shared";
import { useUpdateTaskMutation, useDeleteTaskMutation } from "./tasksApi";
export const TaskItem = memo(function TaskItem({ task, query }: { task: TaskDto; query: TaskQuery }) {
const [updateTask] = useUpdateTaskMutation();
const [deleteTask, { isLoading: deleting }] = useDeleteTaskMutation();
return (
<li className={task.done ? "done" : undefined}>
<input
type="checkbox"
checked={task.done}
onChange={() => updateTask({ id: task.id, query, done: !task.done })}
aria-label={`Mark ${task.title} as ${task.done ? "open" : "done"}`}
/>
<span>{task.title}</span>
<span className={`badge badge-${task.priority}`}>{task.priority}</span>
{task.dueDate && <time dateTime={task.dueDate}>{new Date(task.dueDate).toLocaleDateString()}</time>}
<button disabled={deleting} onClick={() => deleteTask(task.id)} aria-label={`Delete ${task.title}`}>π</button>
</li>
);
});// features/tasks/NewTaskForm.tsx
import { useState } from "react";
import { TASK_PRIORITIES, type TaskPriority } from "@app/shared";
import { useCreateTaskMutation } from "./tasksApi";
export function NewTaskForm() {
const [title, setTitle] = useState("");
const [priority, setPriority] = useState<TaskPriority>("medium");
const [createTask, { isLoading }] = useCreateTaskMutation();
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!title.trim()) return;
await createTask({ title: title.trim(), priority }).unwrap();
setTitle("");
}
return (
<form onSubmit={onSubmit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="What needs doing?" maxLength={200} />
<select value={priority} onChange={(e) => setPriority(e.target.value as TaskPriority)}>
{TASK_PRIORITIES.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
<button disabled={isLoading}>Add</button>
</form>
);
}π How a Single Click Flows Through the Stack
Checkbox click
β updateTask({ id, query, done: true }) RTK Query mutation
β onQueryStarted patches cached list β UI updates instantly optimistic
β PATCH /api/tasks/:id Authorization: Bearer β¦ baseQueryWithReauth
β requireAuth β validate(updateRules) β controller
β taskService.update β findOneAndUpdate({ _id, owner }) ownership enforced in DB query
β 200 TaskDto β invalidatesTags Task:id β list refetches confirms server truth
(on failure β undo patch β error toast via middleware)π Real-World Impact
- Scoping every query by
ownerin the service (not the controller) prevents IDOR β the #1 API vulnerability (OWASP API1). - A single
toTaskDtomapper guarantees the API shape matches the shared type and never leaks internal fields. - Splitting client state (filters slice) from server state (RTK Query cache) keeps both simple.
π― Likely Interview Questions
- Walk me through what happens when a user marks a task done. β Use the flow above.
- How do you stop user A from editing user B's task? β Include
owner: req.user.idin the DB filter; return 404. - Why convert Mongoose documents to DTOs? β Control the public shape, convert ObjectId/Date to strings, never leak secrets.
- Why are dates strings in the shared type? β JSON has no Date type; send ISO strings and parse on the client.
- Which index would you add for the list query? β Compound
{ owner: 1, done: 1, createdAt: -1 }(ESR rule). - Where does the filter state live and why? β Redux slice (client state); fetched tasks in RTK Query (server state).
- How does the list update after creating a task? β
invalidatesTags: [Task:LIST]triggers a refetch of list queries. - How would you add real-time updates? β Socket.IO event on change β
api.util.invalidateTagsorupdateQueryDatain a listener.
β React with TypeScript | Index | Next: Testing MERN + TypeScript β