This is the lesson where the Job Tracker becomes real. The routes and the router stay exactly as they were. Only the body of each controller function changes, from “work on an array” to “work on MongoDB”. That is the payoff of the structure from lesson 11.
The four operations
// Lesson 17: the same routes, now backed by MongoDB.
// Express 5 passes errors from async functions to the error handler, so we do not need try/catch everywhere.
import Job from '../models/Job.js';
import { HttpError } from '../utils/HttpError.js';
import { pickJobFields } from '../middleware/validate.js';
const mustFind = (job) => {
if (!job) throw new HttpError(404, 'Job not found');
return job;
};
export const listJobs = async (req, res) => {
const jobs = await Job.find().sort({ createdAt: -1 });
res.json(jobs);
};
export const getJob = async (req, res) => res.json(mustFind(await Job.findById(req.params.id)));
export const createJob = async (req, res) => {
const job = await Job.create(pickJobFields(req.body)); // Create: validation runs automatically
res.status(201).json(job);
};
export const updateJob = async (req, res) => {
// new: true returns the changed document; runValidators applies the schema rules to the update too
const job = await Job.findByIdAndUpdate(req.params.id, pickJobFields(req.body), { new: true, runValidators: true });
res.json(mustFind(job));
};
export const deleteJob = async (req, res) => {
mustFind(await Job.findByIdAndDelete(req.params.id));
res.status(204).end();
};
- Create:
Job.create(data)builds a document, runs validation and saves it. It returns the saved document, including its new_id. - Read:
Job.find(filter)returns an array;Job.findById(id)returns one document ornull. Chain.sort({ createdAt: -1 })to order newest first. - Update:
Job.findByIdAndUpdate(id, changes, options). The optionnew: truereturns the document *after* the change (the default returns the old one).runValidators: trueis essential: update operations skip the schema rules unless you ask for them, which would let{ status: 'maybe' }slip in. - Delete:
Job.findByIdAndDelete(id)removes it and returns the removed document, ornull.
Every one of these is asynchronous, so every one needs await. Because the controller functions are async and Express 5 forwards rejected promises to the error handler, you do not need try/catch in each one. A validation failure inside create simply throws, and the error middleware answers.
Do not trust the body: pick the fields
import mongoose from 'mongoose';
import { HttpError } from '../utils/HttpError.js';
// router.param('id', checkId): runs for every route that has :id and stops bad ids early.
export const checkId = (req, res, next, id) => {
if (!mongoose.isValidObjectId(id)) return next(new HttpError(404, 'Job not found'));
next();
};
// Only copy the fields we allow. If a client sends { "user": "someone else", "isAdmin": true }, they are ignored.
const JOB_FIELDS = ['company', 'role', 'location', 'status', 'salaryNote', 'notes', 'appliedOn'];
export const pickJobFields = (body = {}) =>
Object.fromEntries(JOB_FIELDS.filter((f) => body[f] !== undefined).map((f) => [f, body[f]]));
pickJobFields copies only the fields we allow. Passing req.body straight to Job.create is a classic security hole: a client could add fields you never meant to be settable. A whitelist makes the allowed fields explicit. (In lesson 22 the owner field is added by the server from the signed-in user and never read from the body, which is the most important use of this habit.)
checkId is a param middleware: router.param('id', checkId) runs for every route that has :id in the path. A text that is not a valid ObjectId would otherwise make Mongoose throw a CastError. Here it answers 404 straight away, which also avoids confirming anything about how ids look.
import { Router } from 'express';
import { listJobs, getJob, createJob, updateJob, deleteJob } from '../controllers/jobs.js';
import { checkId } from '../middleware/validate.js';
const router = Router();
router.param('id', checkId);
router.get('/', listJobs);
router.post('/', createJob);
router.get('/:id', getJob);
router.put('/:id', updateJob);
router.delete('/:id', deleteJob);
export default router;
Translating database errors
export const notFound = (req, res) => {
res.status(404).json({ message: `No route for ${req.method} ${req.originalUrl}` });
};
// Turn the errors Mongoose and MongoDB throw into friendly answers.
function translate(err) {
if (err.name === 'ValidationError') {
const details = Object.fromEntries(Object.entries(err.errors).map(([k, e]) => [k, e.message]));
return { status: 400, message: 'Please fix the highlighted fields', details };
}
if (err.name === 'CastError') return { status: 400, message: `${err.path} is not valid` };
if (err.code === 11000) {
const field = Object.keys(err.keyPattern || {})[0] || 'value';
return { status: 409, message: `That ${field} is already used`, details: { [field]: `That ${field} is already used` } };
}
if (err.type === 'entity.parse.failed') return { status: 400, message: 'The request body is not valid JSON' };
return null;
}
// eslint-disable-next-line no-unused-vars
export const errorHandler = (err, req, res, next) => {
const known = translate(err);
const status = known?.status || err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({
message: known?.message || (status >= 500 ? 'Something went wrong on the server' : err.message),
...((known?.details || err.details) ? { details: known?.details || err.details } : {}),
});
};
translate recognises the errors that Mongoose and MongoDB throw and turns them into friendly answers. A ValidationError becomes 400 with one message per field, in the same shape as lesson 12, so a React form can show them. A CastError becomes 400. A MongoDB duplicate-key error (code 11000, which you will meet for emails in lesson 20) becomes 409 Conflict. Anything else stays a generic 500 and is logged.
Your task
- Start the server with your connection string and run
npm run smoke. - Open your database in Atlas (or Compass) and find the
jobscollection that was created by the firstPOST. - Restart the server and call
GET /api/jobs. The data is still there. That is persistence. - Try to update a job with
{ "status": "maybe" }. Then removerunValidators: truefrom the update and try again. What changed?
How to check it worked
- The smoke script prints All checks passed.
- Data survives a server restart.
- An invalid status on update answers 400 with a message for
status.
Summary
create,find,findById,findByIdAndUpdate,findByIdAndDeletecover CRUD.- Updates need
{ new: true, runValidators: true }. - Whitelist body fields; validate ids; translate database errors into 400, 404 and 409.