PublicLawyerControllerLearnings
publicLawyerController.js — Code-Block-by-Code-Block
This walks the file top to bottom, in the order the code actually runs. For every chunk of code, everything relevant : software design, security, JavaScript mechanics, Mongoose, MongoDB, algorithms, concurrency, networking, date/time : is explained together, right where it happens, instead of grouped into separate topic chapters. Small bracket tags like [Security] or [JS] are kept so you can still recognize what kind of concept you're looking at, but nothing is split into its own section anymore.
Block 0 — Imports and file-level setup
const Lawyer = require('../models/lawyer');
const Client = require('../models/client');
const Availability = require('../models/availibility');
const asyncHandler = require('../middleware/asyncHandler');
const AppError = require('../utils/AppError');
const { sendSuccess, paginationMeta } = require('../utils/apiResponse');
[Networking/Node basics]
require(...)is Node.js's module system — it pulls in code defined in another file. Each line gives this file a local variable (Lawyer,Client, etc.) pointing at whatever that other file exports.[JS] The last line is destructuring an object export:
apiResponse.jsexports an object with (at least) two functions on it,sendSuccessandpaginationMeta, and this line pulls both out into standalone variables in one shot, instead of writingconst sendSuccess = require('../utils/apiResponse').sendSuccess;twice.[SE — audience-based separation] The fact that this file is called *
public*LawyerController, and only ever imports things needed to read and format lawyer data (never anything for a lawyer editing their own profile), is itself a design signal: this file only serves anonymous/public callers, never a lawyer viewing their own account. A sibling file (lawyerProfileController.js, not shown here) handles that other audience. Splitting by who's allowed to call this — not just what it technically does — means this whole file can safely assume "never trust the caller" with no exceptions.
Block 1 — LAWYER_FIELDS (the shared projection object)
const LAWYER_FIELDS = {
specialization: 1,
yearsOfExperience: 1,
courtsPracticed: 1,
languages: 1,
consultationFee: 1,
bio: 1,
profilePhoto: 1,
officeAddress: 1,
rating: 1,
reviewCount: 1,
totalConsultations: 1,
};
[MongoDB — projection] This is a MongoDB projection object.
1means "include this field in the result." You could use0to mean "exclude," but you generally can't mix1s and0s in the same object (except_id). This object gets reused later inside$projectstages (see Block 3.9 and Block 5) — written once here, spread wherever needed.[Security — data minimization] Look at what's missing: no
verificationStatus, noisProfileVisible, nothing payment-related. Because this object later feeds MongoDB's own$projectstage, those excluded fields are never even pulled out of the database for this request — not "fetched then hidden," but never fetched at all. That's a stronger guarantee than filtering in application code, and it's the core of least privilege: only touch the data you're actually willing to expose.[SE — DRY] Because this is a single named constant reused by three different handlers (
getLawyers,getLawyerById,getLawyerRankings), the "list of public-safe fields" only has to be written and maintained in one place.
Block 2 — formatLawyer(raw)
function formatLawyer(raw) {
return {
_id: raw._id,
user: { _id: raw.user._id, name: raw.user.name },
specialization: raw.specialization,
yearsOfExperience: raw.yearsOfExperience,
courtsPracticed: raw.courtsPracticed,
languages: raw.languages,
consultationFee: raw.consultationFee,
bio: raw.bio,
profilePhoto: raw.profilePhoto,
officeAddress: raw.officeAddress,
rating: raw.rating,
reviewCount: raw.reviewCount,
totalConsultations: raw.totalConsultations,
};
}
[SE — shared reshape function] This function is called from
getLawyers,getLawyerById, andgetLawyerRankings(Block 3.12, Block 4, Block 5) — three handlers that fetch data in different ways (aggregation pipeline vs..populate()), but all funnel their result through this one function before responding. That guarantees the frontend always sees the exact same "lawyer object" shape no matter which endpoint it called — one parser, not three slightly-different ones that drift apart over time.[JS — pure function] Given the same
rawinput, this always returns the same output, and it never touches anything outside itself (no DB calls, no global state). That predictability is exactly why it's safe to call from three unrelated places — nothing calling it can accidentally affect anything else calling it.[Security] Notice it explicitly hand-picks each field by name rather than doing something like
return raw;. Even thoughLAWYER_FIELDSalready limited what the database sends back, this function is a second, independent safety net at the application layer — an extra field accidentally left inrawstill won't leak out through here.[JS — nested object literal]
user: { _id: raw.user._id, name: raw.user.name }builds a brand-new nested object rather than just doinguser: raw.user— again deliberately picking only two sub-fields instead of passing the wholeraw.userobject through.
Block 3 — exports.getLawyers
3.1 — The wrapper
exports.getLawyers = asyncHandler(async (req, res) => {
// ...
});
[SE — DRY error handling] In plain Express, if an
asyncfunction throws, Express does not automatically catch it — you'd normally needtry { ... } catch (err) { next(err); }in every handler.asyncHandleris a small reusable wrapper (defined inmiddleware/asyncHandler.js) that does this once, generically, for whatever function you hand it. Everythrow new AppError(...)you'll see below automatically gets routed to Express's central error handler because of this wrapper — no repeated boilerplate needed in each handler.[JS]
async (req, res) => {...}is an arrow function markedasync, meaning you canawaitpromises inside it (see 3.10) and it always returns a promise itself.
3.2 — Destructuring the query string
const {
specialization, court, language, minFee, maxFee, minExperience, minRating, name,
sortBy = 'rating', order = 'desc', page = 1, limit = 20,
} = req.query;
[JS — destructuring with defaults] Pulls each named property off
req.queryinto its own variable in one line.= 'rating',= 'desc',= 1,= 20are default values — they only kick in if that specific key isundefined(i.e., the caller genuinely never sent it). A subtlety: the default only fires forundefined, not for an empty string — ifsortBywere somehow'', the default would not apply.[Networking — query strings are text]
req.querycomes from the URL's query string (everything after?). HTTP doesn't have a "number type" in URLs — every value here, evenpageandlimit, arrives as text ("3", not3), unless it was never sent at all (in which case the JS default kicks in as a real number, since1and20above are written as numeric literals). This is whyNumber(...)shows up repeatedly further down — the code can't assume these are already numbers.
3.3 — The base "bookable" filter
const match = { isProfileVisible: true, verificationStatus: 'approved' };
[SE — DRY violation, flagged] This exact object appears, retyped from scratch, in
getLawyerById(Block 4),getLawyerRankings(Block 5), andgetLawyerAvailability(Block 7) too. It's the file's one business rule for "is this lawyer real and currently bookable?" — but it has no single shared source of truth. If that rule ever needs a 5th condition (say,isSuspended: false), a developer has to remember to update all four spots; missing one creates a real bug.[MongoDB] This is a filter object that will be handed to
$match(3.9) — MongoDB's equivalent of a SQLWHEREclause.
3.4 — Optional-auth blocked-lawyer exclusion
if (req.user?.role === 'client') {
const client = await Client.findOne({ userId: req.user.userId }).select('blockedLawyers');
if (client?.blockedLawyers?.length) {
match._id = { $nin: client.blockedLawyers };
}
}
[Networking — optional auth on a public route] This whole route is reachable by anonymous, logged-out visitors — nothing forces a login. So
req.usermight simply not exist, and every check here has to account for that explicitly rather than assuming a logged-in caller.[JS — optional chaining
?.]req.user?.rolereads: "ifreq.userisnull/undefined, stop right there and produceundefined— don't try to read.roleoff of nothing." Without?., this would throw aTypeErrorand crash the request whenever there's no logged-in user. Sinceundefined === 'client'is simplyfalse, theifblock is just skipped for anonymous visitors — no crash, no special-casing needed.[Mongoose —
.select()].select('blockedLawyers')tells Mongoose to fetch only that one field (plus_id) instead of the client's entire document. This runs on every search request from a logged-in client, so keeping it minimal is worth it — there's no reason to pull someone's full profile just to check one array.[JS — chained optional chaining]
client?.blockedLawyers?.lengthprotects against two possible gaps:clientitself could benull(defensively handled, even though it normally shouldn't be), and even ifclientexists,blockedLawyerscould theoretically be missing. Either?.short-circuits safely toundefined, andif (undefined)is falsy, so the block is skipped.[MongoDB —
$nin] If the client does have blocked lawyers,match._id = { $nin: client.blockedLawyers }means "exclude any lawyer whose_idis in this list." This is the actual enforcement point of "hide blocked lawyers from search results."[Security] This is a server-side enforcement of a privacy preference — the client never gets a chance to see a blocked lawyer in results at all, rather than the frontend being trusted to filter them out after the fact.
3.5 — Optional filters
if (specialization) match.specialization = { $in: specialization.split(',') };
if (court) match.courtsPracticed = { $in: court.split(',') };
if (language) match.languages = { $in: language.split(',') };
if (minFee || maxFee) {
match.consultationFee = {};
if (minFee) match.consultationFee.$gte = Number(minFee);
if (maxFee) match.consultationFee.$lte = Number(maxFee);
}
if (minExperience) match.yearsOfExperience = { $gte: Number(minExperience) };
if (minRating) match.rating = { $gte: Number(minRating) };
[Networking — comma-separated multi-value convention]
?specialization=property,criminalis one common REST convention for "filter by more than one value on the same field." This code accepts a single query param and manually splits it, rather than the alternative convention of repeating the key (?specialization=property&specialization=criminal).[MongoDB —
$in] Matches a document if its field's value overlaps with any value in the given array — so a lawyer practicing in eitherpropertyorcriminallaw would matchspecialization.split(',')from that example.[MongoDB —
$gte/$lte] "Greater than or equal" / "less than or equal."minFeeandmaxFeeare built independently, so a caller can supply just one, both (giving a true "between X and Y" range), or neither.[JS —
Number(...)conversions] Every one of these values arrived fromreq.queryas text (see 3.2's networking note), so each gets explicitly converted withNumber(...)before being compared numerically by MongoDB.[Security — gap, not present here] None of these values are clamped or bounds-checked (contrast with
getLawyerRankings'slimithandling in Block 5.1) — not dangerous on their own, but worth noticing as an inconsistency within the same file.
3.6 — Sort field whitelist
const sortFieldMap = {
rating: 'rating',
fee: 'consultationFee',
experience: 'yearsOfExperience',
consultations: 'totalConsultations',
};
const sortField = sortFieldMap[sortBy] || 'rating';
[Security — whitelisting via lookup table] The tempting shortcut would be
{ $sort: { [sortBy]: sortOrder } }— sorting directly on whatever string the caller sent. The problem: nothing would then stop a caller from sending?sortBy=verificationStatusor an internal field name never meant to be exposed as a sort key.sortFieldMapis a whitelist: a fixed, hand-written set of the only four acceptable values. Anything else looked up on it returnsundefined.[JS — bracket notation with a dynamic key]
sortFieldMap[sortBy]looks up a property using the value currently stored insortBy— different fromsortFieldMap.sortBy, which would look for a literal property named"sortBy"(which doesn't exist).[JS —
||fallback, used correctly] If the lookup returnsundefined(an unrecognizedsortBy),|| 'rating'supplies a safe default.||(not??) is fine here becausesortFieldMap[sortBy]can only ever be a real string orundefined— never a legitimate0/''/falsethat||might wrongly overwrite (contrast with Block 5.3, where that distinction matters a lot).
3.7 — Sort direction
const sortOrder = order === 'asc' ? 1 : -1;
[JS — ternary] A compact if/else that produces a value. If
order === 'asc'is true, this expression is1; otherwise-1.[MongoDB] MongoDB's
$sortonly understands1(ascending) /-1(descending) — not the words"asc"/"desc"— so this line is translating the human-readable query param into what MongoDB actually expects. Anything other than exactly"asc"(a typo, or noorderat all) silently falls through to-1.
3.8 — Pagination math
const skip = (Number(page) - 1) * Number(limit);
[JS]
pageandlimitare converted to real numbers first (see 3.2's networking note about why this matters —"3" - 1happens to coerce to2automatically in JS, but relying on that inconsistently across operators like+is a well-known trap, so this code converts explicitly instead of leaning on it).[MongoDB/Algorithm — offset pagination] For page 1,
skip = 0. For page 2 withlimit = 20,skip = 20— skip the first 20 matches, then return the next batch. This is standard offset pagination. Worth knowing as a tradeoff, not a bug:$skipgets slower the deeper you page, because MongoDB genuinely walks past every skipped document rather than jumping straight to a spot. Fine at moderate scale; a different technique (cursor-based pagination) scales better for very deep pages but is more complex.
3.9 — Building the aggregation pipeline
const basePipeline = [
{ $match: match },
{ $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } },
{ $unwind: '$user' },
...(name ? [{ $match: { 'user.name': { $regex: name, $options: 'i' } } }] : []),
];
[MongoDB — aggregation pipeline] An aggregation pipeline is a sequence of stages, each one transforming the output of the stage before it — like a factory line. This lets multi-step data processing (filtering, joining, computing) happen inside the database instead of in application code.
[MongoDB —
$match] Runs first, deliberately — narrowing the working set (and letting MongoDB use indexes) before the more expensive join happens next.[MongoDB —
$lookup] A server-side join, conceptually like SQL'sJOIN.from: 'users'is the raw MongoDB collection name (lowercase, pluralized — Mongoose's naming convention for a model calledUser).localField/foreignFieldsay which fields to match on;as: 'user'names where the joined data lands. This runs as one join inside MongoDB itself, in the same query — unlike.populate()(see Block 4), which is a separate follow-up query.[MongoDB —
$unwind]$lookupalways produces an array (user: [...]), even though there's guaranteed to be exactly one matchingUser(the schema enforcesuserId: { unique: true }).$unwindflattens that single-element array into a plain object,user: {...}— which is what makes'user.name'a valid path in the next line and informatLawyer.[JS — conditional array spread]
...(name ? [{...}] : [])evaluates to either a one-element array (spread intobasePipelineas a real 4th stage) or an empty array (spreading in nothing at all). This is the array equivalent of anifstatement, used because you can't easily "not add an element" with a plainifinside an array literal.[Security — flagged, real gap]
namecomes straight fromreq.query.nameand flows directly into MongoDB's$regexwith no escaping.$regexinterprets its input as an actual regex pattern (special characters like.,*,(,|change meaning), not plain text. A caller could craft a deliberately expensive pattern that makes MongoDB slow to evaluate against every document — a ReDoS (Regular Expression Denial of Service) risk. The fix — not present in this file — is to escape regex-special characters innamebefore using it, so it only ever matches literal text.
3.10 — Running both queries concurrently
const [lawyersRaw, countResult] = await Promise.all([
Lawyer.aggregate([
...basePipeline,
{ $sort: { [sortField]: sortOrder } },
{ $skip: skip },
{ $limit: Number(limit) },
{ $project: { ...LAWYER_FIELDS, 'user._id': 1, 'user.name': 1 } },
]),
Lawyer.aggregate([...basePipeline, { $count: 'total' }]),
]);
[Concurrency —
Promise.all] These two aggregations are independent — neither needs the other's result. Running them with two separateawaits would mean waiting for the first to fully finish before even starting the second.Promise.all([...])starts both at roughly the same time, and the combinedawaitresolves once both finish — total wait time is close to whichever is slower, not the sum of both.[JS — array destructuring of the result]
Promise.allresolves to an array of results in the same order the promises were given.const [lawyersRaw, countResult] = ...pulls the first aggregation's result intolawyersRawand the second's intocountResult, purely by position.[JS — computed property name]
{ [sortField]: sortOrder }uses the value stored insortField(e.g."consultationFee") as the object key, not the literal text"sortField".[MongoDB —
$sort/$skip/$limit] Order, offset, and page size, applied in that sequence.[Security — flagged gap] Unlike
getLawyerRankings(Block 5.1),limithere is never clamped — a caller could send?limit=999999999and force MongoDB to try building a huge result set in one go. This is a real resource-exhaustion risk, and notably the developer clearly knows the clamping pattern (they use it elsewhere in this same file) but didn't apply it here.[MongoDB —
$project] SpreadsLAWYER_FIELDS(Block 1) plus two more dotted paths reaching into theusersub-object created by$unwind.[MongoDB —
$count] Collapses the whole pipeline's current result set into one document,{ total: <number> }. If zero documents match, the output is an empty array, not[{ total: 0 }]— which matters for the next block.[SE — why run this as a second, separate query]
$skip/$limitonly return the current page; there's no way to also learn "and there were 347 total matches" from that same query. So the file runs the filtering logic twice — once as a realized page of documents, once collapsed into a count — both starting from the identicalbasePipelineso they always agree on what "counts" as a match.
3.11 — Total and formatted list
const total = countResult[0]?.total || 0;
const lawyers = lawyersRaw.map(formatLawyer);
[JS — optional chaining +
||together]countResult[0]could beundefined(an empty array has no index0, per the$countnote above).?.totalsafely handles that.|| 0then supplies a plain0if eithercountResult[0]was missing or.totalitself was somehow falsy.[JS — passing a function reference to
.map()].map(formatLawyer)hands.map()the function itself (no wrapping arrow function needed), becauseformatLawyeralready takes exactly the one argument.map()provides per element and returns exactly what's needed for each output element.
3.12 — Response
return sendSuccess(res, 200, { lawyers }, paginationMeta({ total, page, limit }));
[SE — consistent response envelope] Every success response in the whole API funnels through
sendSuccess, so every endpoint's response has the same overall shape regardless of which handler produced it.[Networking — pagination metadata]
lawyersalone wouldn't tell a frontend how many total matches exist or which page it's viewing.paginationMeta({ total, page, limit })builds that context so a UI can render something like "Page 2 of 18."
Block 4 — exports.getLawyerById
exports.getLawyerById = asyncHandler(async (req, res) => {
const lawyer = await Lawyer.findOne({
_id: req.params.id,
isProfileVisible: true,
verificationStatus: 'approved',
})
.populate('userId', 'name')
.lean();
if (!lawyer) {
throw new AppError('Lawyer not found.', 404);
}
const formatted = formatLawyer({ ...lawyer, user: lawyer.userId });
let isSaved = false;
let isBlocked = false;
if (req.user) {
const client = await Client.findOne({ userId: req.user.userId });
if (client) {
isSaved = client.savedLawyers.some((id) => id.equals(lawyer._id));
isBlocked = client.blockedLawyers.some((id) => id.equals(lawyer._id));
}
}
return sendSuccess(res, 200, { lawyer: formatted, isSaved, isBlocked });
});
[SE — same DRY gap as 3.3] The
isProfileVisible: true, verificationStatus: 'approved'filter is retyped here, identically, for the second of four times in this file.[Mongoose —
.findOne()+.populate()]Lawyerdocuments storeuserIdas just a reference (an ObjectId) to a separateUserdocument..populate('userId', 'name')tells Mongoose "also fetch that referencedUserand swap the raw ID for the actual object (just itsnamefield)." This runs as a separate second query under the hood — a reasonable cost for one document, but not something you'd want per-item in a list (see 3.9's$lookupnote for why lists use aggregation instead).[Mongoose —
.lean()] Without.lean(), a Mongoose query result is wrapped in a full Document instance (with.save(), change tracking, etc.) — overhead this handler never needs, since it only reads and reshapes data, never writes it back..lean()returns a plain object instead, skipping that machinery.[Security — information hiding via identical errors] Two very different situations — "this
_iddoesn't exist at all" and "this_idexists but the lawyer is unapproved/hidden" — both makelawyercome backnullhere, because the visibility filter is baked into the samefindOnecall as the_idcheck. Both produce the exact same 404. This deliberately prevents an outside visitor from being able to tell "no such lawyer" apart from "that lawyer is suspended," which would otherwise leak information about lawyer status.[Networking — 404 semantics]
404here means "not found from this caller's point of view" — a deliberate, security-motivated use of the status code (contrast with the400s intoUtcMidnight, Block 6, which mean "you sent something malformed").[JS — object spread with key override]
{ ...lawyer, user: lawyer.userId }copies every property offlawyerinto a new object, then adds/overwrites auserkey pointing atlawyer.userId— becauseformatLawyerexpects auserkey, but after.populate()the populated data actually sits underuserId(matching the schema's own field name). Becauseuser: lawyer.userIdis written after the spread, it wins if there was any conflict. The originallawyerobject is never mutated — spread always builds a new object.[JS —
letvsconst]isSaved/isBlockedstart asfalsebut might get reassigned below depending on login state —constwould be a syntax error here since reassignment is exactly what happens.[Networking — optional auth]
if (req.user)— this route works for anonymous visitors too; personalization (isSaved/isBlocked) only happens for logged-in callers.[JS —
Array.prototype.some()] Runs a test against every array element and returnstruethe moment any element passes (stopping early once it finds a match). Here: "does this client's saved/blocked list contain this lawyer's ID?"[Mongoose —
ObjectId.equals(), not===] MongoDB IDs are objects, not primitives — twoObjectIdinstances representing the same underlying ID are still different objects in memory, so===between them isfalseeven for a genuine match (and.includes()would break the same way, since it uses===internally)..equals()does a real value comparison instead. Always use.equals()for ObjectId comparisons in Mongoose, never===or.includes().[SE — reused
formatLawyer] Same shared function from Block 2, guaranteeing this response'slawyerfield looks identical in shape to whatgetLawyersreturns.
Block 5 — exports.getLawyerRankings
5.1 — Setup and clamped pagination
const { specialization, page = 1, limit = 50 } = req.query;
const pageNumber = Math.max(1, Number(page) || 1);
const limitNumber = Math.min(100, Math.max(1, Number(limit) || 50));
[Security — clamping, done right here] Unlike
getLawyers(3.10),limitNumberis explicitly bounded between1and100no matter what the caller sends —?limit=999999999can't force a huge query here.Math.max(1, ...)prevents0or negative values;Math.min(100, ...)prevents excessively large ones.[JS]
Number(limit) || 50falls back to50ifNumber(limit)isNaN(e.g. someone sent?limit=abc) —NaNis falsy, so||catches that case too, on top of the destructuring default only covering a totally missing param.
5.2 — Match filter and base pipeline
const match = { isProfileVisible: true, verificationStatus: 'approved' };
if (specialization) match.specialization = { $in: specialization.split(',') };
const basePipeline = [
{ $match: match },
{ $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } },
{ $unwind: '$user' },
];
[SE — the DRY gap again] Third appearance of the bookable filter, retyped from scratch.
Same
$match/$lookup/$unwindmechanics as Block 3.9, minus thename-regex stage (this endpoint doesn't support name search).
5.3 — Getting the value range and total, concurrently
const [rangeResult, countResult] = await Promise.all([
Lawyer.aggregate([
{ $match: match },
{ $group: { _id: null, min: { $min: '$totalConsultations' }, max: { $max: '$totalConsultations' } } },
]),
Lawyer.aggregate([{ $match: match }, { $count: 'total' }]),
]);
const total = countResult[0]?.total || 0;
const range = rangeResult[0];
const minConsultations = range?.min ?? 0;
const maxConsultations = range?.max ?? 0;
const flatVolume = maxConsultations === minConsultations;
[Concurrency] Same
Promise.allreasoning as 3.10 — the min/max computation and the total count don't depend on each other, so both run at once.[MongoDB —
$groupwith_id: null] Normally$groupbuckets documents by some field's distinct values._id: nullmeans "don't bucket at all — collapse every matched document into one single group," which is what lets$min/$maxcompute across the entire matched set at once.[MongoDB —
$min/$maxaccumulators] Computed inside the database, in one pass, across every matched document — far cheaper than pulling every lawyer's rawtotalConsultationsback to the app server just to runMath.min/Math.maxin JavaScript.[JS — nullish coalescing
??, not||] This is the one spot in the file where the??vs||distinction genuinely matters: if a brand-new approved lawyer has done zero consultations,range.mincould legitimately be0— a real, meaningful value, not "missing data."||would treat that0as falsy and wrongly try to replace it;??only falls back when the value is trulynull/undefined(i.e.,rangeitself isundefinedbecause zero lawyers matched at all). Compare this directly with 3.6, where||was the right tool — same operator family, opposite correctness depending on whether0can be a legitimate value.[Algorithm — division-by-zero guard]
flatVolumeprecomputes whethermax === min(every matched lawyer tied, or only one/zero matched) before the next block's formula would otherwise divide by(max - min) = 0. This is checked deliberately rather than letting the pipeline silently produceNaN/Infinity.
5.4 — Skip, and the scoring pipeline
const skip = (pageNumber - 1) * limitNumber;
const rankingsRaw = await Lawyer.aggregate([
...basePipeline,
{
$addFields: {
ratingScore: { $cond: [{ $gt: ['$rating', 0] }, { $divide: ['$rating', 5] }, 0] },
volumeScore: flatVolume
? 0.5
: { $divide: [{ $subtract: ['$totalConsultations', minConsultations] }, { $subtract: [maxConsultations, minConsultations] }] },
},
},
{
$addFields: {
score: { $multiply: [{ $add: [{ $multiply: [0.4, '$ratingScore'] }, { $multiply: [0.6, '$volumeScore'] }] }, 100] },
},
},
{ $sort: { score: -1, rating: -1, totalConsultations: -1, _id: 1 } },
{ $skip: skip },
{ $limit: limitNumber },
{ $project: { ...LAWYER_FIELDS, 'user._id': 1, 'user.name': 1, score: 1 } },
]);
[MongoDB —
$cond][condition, thenValue, elseValue].ratingScorereads: "ifrating > 0, computerating / 5; otherwise0." (Mathematically0 / 5is also0, so the guard is somewhat redundant here — but it states the intent explicitly rather than relying on that coincidence.)[Algorithm — min-max normalization]
volumeScore's formula, when not flat, is(totalConsultations - min) / (max - min)— the standard way to rescale any raw number into a fair0–1range relative to the current matched set. WhenflatVolumeis true, everyone instead gets a neutral0.5— the mathematically honest answer when there's no real variation to normalize against.[MongoDB —
$divide/$subtract/$multiply/$add] MongoDB's aggregation syntax for ordinary arithmetic, written as{ $operator: [a, b] }instead ofa - betc., computed per-document, inside the database.[MongoDB — two sequential
$addFieldsstages] The second stage'sscoreformula references'$ratingScore'and'$volumeScore', both defined by the first stage. Fields being computed within a single$addFieldsstage generally can't reliably reference each other yet — splitting into two stages guarantees stage 1 fully finishes and attaches both scores before stage 2 reads them.[Algorithm — weighted composite score]
score = (0.4 * ratingScore + 0.6 * volumeScore) * 100— two0–1sub-scores combined with explicit weights (40% rating, 60% volume) and scaled to a friendlier0–100range.[Algorithm — multi-key deterministic sort]
{ score: -1, rating: -1, totalConsultations: -1, _id: 1 }sorts byscorefirst, but if two lawyers tie exactly, falls through torating, thentotalConsultations, then finally_id— which is guaranteed unique, so this last tiebreaker eliminates all possible ties. Without a fully deterministic sort, tied documents' relative order across separate page-1/page-2 requests would technically be unspecified — meaning a lawyer could seem to vanish or duplicate between pages purely due to how MongoDB happened to break the tie that time.[MongoDB —
$skip/$limit/$project] Same mechanics as Block 3.10, withscore: 1added to the projection so it survives into the output.
5.5 — Shaping and responding
const rankings = rankingsRaw.map((raw, index) => ({
rank: skip + index + 1,
score: Math.round(raw.score * 10) / 10,
lawyer: formatLawyer(raw),
}));
return sendSuccess(res, 200, { rankings }, paginationMeta({ total, page: pageNumber, limit: limitNumber }));
[JS —
.map()'s secondindexargument].map()'s callback receives the current element's position (0-based) as a second parameter.[Algorithm — global rank under pagination]
skip + index + 1converts "this lawyer's position within the current page" into "this lawyer's position across the entire sorted dataset." This only works because the sort (5.4) ran on the whole matched set before$skip/$limitnarrowed it down — sort first, paginate second. The+1exists because ranks conventionally start at 1, not 0.[JS — multiply/round/divide for decimal precision]
Math.round()only rounds to whole numbers — there's no built-in "round to 1 decimal" in plain JS. Multiplying by 10 first, rounding, then dividing by 10 again is the standard workaround (e.g.87.36 → 873.6 → 874 → 87.4).[SE] Same shared
sendSuccess/paginationMeta/formatLawyerreuse as the earlier handlers.
Block 6 — toUtcMidnight(dateInput) (date helper)
function toUtcMidnight(dateInput) {
const d = new Date(dateInput);
if (Number.isNaN(d.getTime())) {
throw new AppError('Invalid date.', 400);
}
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
[Date/Time —
Dateleniency and Invalid Date] JavaScript'sDateconstructor is very forgiving about input formats, but when it genuinely can't parse something, it doesn't throw — it silently produces an Invalid Date: an object that looks like a normalDatebut represents no real point in time.[JS — detecting it correctly with
Number.isNaN]d.getTime()returnsNaNfor an Invalid Date. You can't check this withd.getTime() === NaN— by a quirk of the underlying numeric standard,NaNnever equals anything, including itself.Number.isNaN(...)is the built-in function specifically designed to detect this correctly.[SE —
AppError] Throwingnew AppError('Invalid date.', 400)lets this singlethrowcarry both a message and an HTTP status code, which theasyncHandlerwrapper (Block 3.1) routes to Express's central error handler — no manualres.status(400).json(...)needed here.[Date/Time — timezone normalization to UTC midnight]
Date.UTC(year, month, day)builds a timestamp for exactly midnight UTC on a given calendar date, discarding whatever time-of-day or timezone the original input carried. This matches howAvailabilitydocuments store their owndatefield. Because MongoDB date comparisons are exact timestamp comparisons — not "same calendar day, loosely interpreted" — a raw, un-normalized date like2026-07-20T15:30:00+05:30would not equal2026-07-20T00:00:00.000Z, and a query using it as-is would silently fail to match documents the caller clearly meant. This function guarantees every date this file uses lines up with that storage convention, regardless of how the caller originally formatted it.[JS — pure function] Same input always produces the same output (or the same thrown error) — no side effects, safe to call from multiple places (it's used twice in Block 7).
Block 7 — exports.getLawyerAvailability
7.1 — Confirming the lawyer is bookable
const lawyer = await Lawyer.findOne({
_id: req.params.id,
isProfileVisible: true,
verificationStatus: 'approved',
});
if (!lawyer) {
throw new AppError('Lawyer not found.', 404);
}
[SE — fourth appearance of the DRY gap] Same bookable filter, retyped a fourth time.
[Security — same identical-error info hiding as Block 4] "Doesn't exist" and "exists but not bookable" again produce the same 404 here.
7.2 — Date range, with validation
const { from, to } = req.query;
const fromDate = from ? toUtcMidnight(from) : toUtcMidnight(new Date());
const filter = { lawyerId: lawyer._id, date: { $gte: fromDate } };
if (to) {
const toDate = toUtcMidnight(to);
if (toDate < fromDate) {
throw new AppError('to must be on or after from.', 400);
}
filter.date.$lte = toDate;
}
[Date/Time] If
fromisn't supplied, it defaults to today (normalized via Block 6), not "the beginning of time" — a sensible default for a booking feature, since you can't book a slot in the past.[MongoDB —
$gte/$lteagain, this time on dates] Same range-filter pattern as Block 3.5, applied todateinstead ofconsultationFee.[SE — explicit validation instead of a silently empty result] Without the
toDate < fromDatecheck, sending an impossible range (tobeforefrom) would just make MongoDB return zero results — indistinguishable, from the caller's side, from "no availability in a perfectly valid range." This code deliberately catches the nonsensical input before querying and returns a clear400, rather than letting a bug hide behind an innocent-looking empty array.[Networking — 400 here, not 404] This is a genuinely malformed/logically-invalid request (the client should change what it sent), which is exactly what
400conventionally signals — contrast with the404s in 7.1 and Block 4, which mean "the request was fine, but the resource doesn't exist/isn't visible."
7.3 — Fetching and reshaping
const availabilityDocs = await Availability.find(filter).sort({ date: 1 }).lean();
const now = new Date();
const availability = availabilityDocs
.map((doc) => ({
availabilityId: doc._id,
date: doc.date,
slots: doc.slots
.filter((slot) => !slot.isBooked && new Date(slot.startTime) > now)
.map((slot) => ({ slotId: slot._id, startTime: slot.startTime, endTime: slot.endTime })),
}))
.filter((day) => day.slots.length > 0);
return sendSuccess(res, 200, { availability });
[Mongoose —
.lean()again] Same reasoning as Block 4 — this is a pure read, so there's no need for the full Document machinery.[Date/Time — capturing
nowonce]nowis captured a single time before filtering starts, rather than callingnew Date()fresh inside the loop for every slot — slightly more efficient, and avoids any inconsistency if the loop somehow spanned real time.[JS — chained
.map().filter().map().filter()] Read top to bottom as a pipeline:Outer
.map()— for eachAvailabilitydocument (one per calendar day), build a new object with a renamedavailabilityIdand aslotsarray that's itself the result of steps 2–3.Inner
.filter()— keep only slots where!slot.isBooked(not taken) andnew Date(slot.startTime) > now(still in the future) — both must be true.Inner
.map()— rebuild each surviving slot with exactly three fields.Outer
.filter()— after the outer.map()finishes, drop any day whoseslotsarray ended up empty, so the response doesn't include clutter like{ date: ..., slots: [] }.
[Date/Time — "only future" filtering]
new Date(slot.startTime) > nowworks because JS automatically convertsDateobjects to their millisecond-timestamp numbers for comparison — so this really means "is this slot's timestamp later than the current timestamp."[Security — excluding internal fields] The inner
.map()deliberately builds a brand-new object with onlyslotId/startTime/endTime— leaving out the raw slot'sbookingId, which would otherwise reveal internal booking-record identifiers to an anonymous browsing visitor with no legitimate reason to see them. Same principle asLAWYER_FIELDS(Block 1), applied to a different data shape.[SE] Final response again goes through the shared
sendSuccessenvelope.
About the author
- MERN || FARM || System Design || Devops
Comments
Join the discussionNo comments yet. Be the first to comment.