Skip to main content

PublicLawyerControllerLearnings

Updated 31 min readMarkdown

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.js exports an object with (at least) two functions on it, sendSuccess and paginationMeta, and this line pulls both out into standalone variables in one shot, instead of writing const 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. 1 means "include this field in the result." You could use 0 to mean "exclude," but you generally can't mix 1s and 0s in the same object (except _id). This object gets reused later inside $project stages (see Block 3.9 and Block 5) — written once here, spread wherever needed.

  • [Security — data minimization] Look at what's missing: no verificationStatus, no isProfileVisible, nothing payment-related. Because this object later feeds MongoDB's own $project stage, 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, and getLawyerRankings (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 raw input, 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 though LAWYER_FIELDS already limited what the database sends back, this function is a second, independent safety net at the application layer — an extra field accidentally left in raw still 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 doing user: raw.user — again deliberately picking only two sub-fields instead of passing the whole raw.user object through.


Block 3 — exports.getLawyers

3.1 — The wrapper

exports.getLawyers = asyncHandler(async (req, res) => {
  // ...
});
  • [SE — DRY error handling] In plain Express, if an async function throws, Express does not automatically catch it — you'd normally need try { ... } catch (err) { next(err); } in every handler. asyncHandler is a small reusable wrapper (defined in middleware/asyncHandler.js) that does this once, generically, for whatever function you hand it. Every throw 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 marked async, meaning you can await promises 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.query into its own variable in one line. = 'rating', = 'desc', = 1, = 20 are default values — they only kick in if that specific key is undefined (i.e., the caller genuinely never sent it). A subtlety: the default only fires for undefined, not for an empty string — if sortBy were somehow '', the default would not apply.

  • [Networking — query strings are text] req.query comes from the URL's query string (everything after ?). HTTP doesn't have a "number type" in URLs — every value here, even page and limit, arrives as text ("3", not 3), unless it was never sent at all (in which case the JS default kicks in as a real number, since 1 and 20 above are written as numeric literals). This is why Number(...) 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), and getLawyerAvailability (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 SQL WHERE clause.

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.user might 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?.role reads: "if req.user is null/undefined, stop right there and produce undefined — don't try to read .role off of nothing." Without ?., this would throw a TypeError and crash the request whenever there's no logged-in user. Since undefined === 'client' is simply false, the if block 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?.length protects against two possible gaps: client itself could be null (defensively handled, even though it normally shouldn't be), and even if client exists, blockedLawyers could theoretically be missing. Either ?. short-circuits safely to undefined, and if (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 _id is 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,criminal is 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 either property or criminal law would match specialization.split(',') from that example.

  • [MongoDB — $gte / $lte] "Greater than or equal" / "less than or equal." minFee and maxFee are 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 from req.query as text (see 3.2's networking note), so each gets explicitly converted with Number(...) before being compared numerically by MongoDB.

  • [Security — gap, not present here] None of these values are clamped or bounds-checked (contrast with getLawyerRankings's limit handling 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=verificationStatus or an internal field name never meant to be exposed as a sort key. sortFieldMap is a whitelist: a fixed, hand-written set of the only four acceptable values. Anything else looked up on it returns undefined.

  • [JS — bracket notation with a dynamic key] sortFieldMap[sortBy] looks up a property using the value currently stored in sortBy — different from sortFieldMap.sortBy, which would look for a literal property named "sortBy" (which doesn't exist).

  • [JS — || fallback, used correctly] If the lookup returns undefined (an unrecognized sortBy), || 'rating' supplies a safe default. || (not ??) is fine here because sortFieldMap[sortBy] can only ever be a real string or undefined — never a legitimate 0/''/false that || 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 is 1; otherwise -1.

  • [MongoDB] MongoDB's $sort only understands 1 (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 no order at all) silently falls through to -1.

3.8 — Pagination math

const skip = (Number(page) - 1) * Number(limit);
  • [JS] page and limit are converted to real numbers first (see 3.2's networking note about why this matters — "3" - 1 happens to coerce to 2 automatically 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 with limit = 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: $skip gets 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's JOIN. from: 'users' is the raw MongoDB collection name (lowercase, pluralized — Mongoose's naming convention for a model called User). localField/foreignField say 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] $lookup always produces an array (user: [...]), even though there's guaranteed to be exactly one matching User (the schema enforces userId: { unique: true }). $unwind flattens that single-element array into a plain object, user: {...} — which is what makes 'user.name' a valid path in the next line and in formatLawyer.

  • [JS — conditional array spread] ...(name ? [{...}] : []) evaluates to either a one-element array (spread into basePipeline as a real 4th stage) or an empty array (spreading in nothing at all). This is the array equivalent of an if statement, used because you can't easily "not add an element" with a plain if inside an array literal.

  • [Security — flagged, real gap] name comes straight from req.query.name and flows directly into MongoDB's $regex with no escaping. $regex interprets 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 in name before 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 separate awaits 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 combined await resolves once both finish — total wait time is close to whichever is slower, not the sum of both.

  • [JS — array destructuring of the result] Promise.all resolves to an array of results in the same order the promises were given. const [lawyersRaw, countResult] = ... pulls the first aggregation's result into lawyersRaw and the second's into countResult, purely by position.

  • [JS — computed property name] { [sortField]: sortOrder } uses the value stored in sortField (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), limit here is never clamped — a caller could send ?limit=999999999 and 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] Spreads LAWYER_FIELDS (Block 1) plus two more dotted paths reaching into the user sub-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/$limit only 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 identical basePipeline so 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 be undefined (an empty array has no index 0, per the $count note above). ?.total safely handles that. || 0 then supplies a plain 0 if either countResult[0] was missing or .total itself was somehow falsy.

  • [JS — passing a function reference to .map()] .map(formatLawyer) hands .map() the function itself (no wrapping arrow function needed), because formatLawyer already 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] lawyers alone 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()] Lawyer documents store userId as just a reference (an ObjectId) to a separate User document. .populate('userId', 'name') tells Mongoose "also fetch that referenced User and swap the raw ID for the actual object (just its name field)." 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 $lookup note 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 _id doesn't exist at all" and "this _id exists but the lawyer is unapproved/hidden" — both make lawyer come back null here, because the visibility filter is baked into the same findOne call as the _id check. 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] 404 here means "not found from this caller's point of view" — a deliberate, security-motivated use of the status code (contrast with the 400s in toUtcMidnight, Block 6, which mean "you sent something malformed").

  • [JS — object spread with key override] { ...lawyer, user: lawyer.userId } copies every property off lawyer into a new object, then adds/overwrites a user key pointing at lawyer.userId — because formatLawyer expects a user key, but after .populate() the populated data actually sits under userId (matching the schema's own field name). Because user: lawyer.userId is written after the spread, it wins if there was any conflict. The original lawyer object is never mutated — spread always builds a new object.

  • [JS — let vs const] isSaved/isBlocked start as false but might get reassigned below depending on login state — const would 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 returns true the 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 — two ObjectId instances representing the same underlying ID are still different objects in memory, so === between them is false even 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's lawyer field looks identical in shape to what getLawyers returns.


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), limitNumber is explicitly bounded between 1 and 100 no matter what the caller sends — ?limit=999999999 can't force a huge query here. Math.max(1, ...) prevents 0 or negative values; Math.min(100, ...) prevents excessively large ones.

  • [JS] Number(limit) || 50 falls back to 50 if Number(limit) is NaN (e.g. someone sent ?limit=abc) — NaN is 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 / $unwind mechanics as Block 3.9, minus the name-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.all reasoning as 3.10 — the min/max computation and the total count don't depend on each other, so both run at once.

  • [MongoDB — $group with _id: null] Normally $group buckets documents by some field's distinct values. _id: null means "don't bucket at all — collapse every matched document into one single group," which is what lets $min/$max compute across the entire matched set at once.

  • [MongoDB — $min / $max accumulators] Computed inside the database, in one pass, across every matched document — far cheaper than pulling every lawyer's raw totalConsultations back to the app server just to run Math.min/Math.max in 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.min could legitimately be 0 — a real, meaningful value, not "missing data." || would treat that 0 as falsy and wrongly try to replace it; ?? only falls back when the value is truly null/undefined (i.e., range itself is undefined because zero lawyers matched at all). Compare this directly with 3.6, where || was the right tool — same operator family, opposite correctness depending on whether 0 can be a legitimate value.

  • [Algorithm — division-by-zero guard] flatVolume precomputes whether max === 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 produce NaN/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]. ratingScore reads: "if rating > 0, compute rating / 5; otherwise 0." (Mathematically 0 / 5 is also 0, 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 fair 0–1 range relative to the current matched set. When flatVolume is true, everyone instead gets a neutral 0.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 of a - b etc., computed per-document, inside the database.

  • [MongoDB — two sequential $addFields stages] The second stage's score formula references '$ratingScore' and '$volumeScore', both defined by the first stage. Fields being computed within a single $addFields stage 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 — two 0–1 sub-scores combined with explicit weights (40% rating, 60% volume) and scaled to a friendlier 0–100 range.

  • [Algorithm — multi-key deterministic sort] { score: -1, rating: -1, totalConsultations: -1, _id: 1 } sorts by score first, but if two lawyers tie exactly, falls through to rating, then totalConsultations, 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, with score: 1 added 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 second index argument] .map()'s callback receives the current element's position (0-based) as a second parameter.

  • [Algorithm — global rank under pagination] skip + index + 1 converts "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/$limit narrowed it down — sort first, paginate second. The +1 exists 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/formatLawyer reuse 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 — Date leniency and Invalid Date] JavaScript's Date constructor 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 normal Date but represents no real point in time.

  • [JS — detecting it correctly with Number.isNaN] d.getTime() returns NaN for an Invalid Date. You can't check this with d.getTime() === NaN — by a quirk of the underlying numeric standard, NaN never equals anything, including itself. Number.isNaN(...) is the built-in function specifically designed to detect this correctly.

  • [SE — AppError] Throwing new AppError('Invalid date.', 400) lets this single throw carry both a message and an HTTP status code, which the asyncHandler wrapper (Block 3.1) routes to Express's central error handler — no manual res.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 how Availability documents store their own date field. Because MongoDB date comparisons are exact timestamp comparisons — not "same calendar day, loosely interpreted" — a raw, un-normalized date like 2026-07-20T15:30:00+05:30 would not equal 2026-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 from isn'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/$lte again, this time on dates] Same range-filter pattern as Block 3.5, applied to date instead of consultationFee.

  • [SE — explicit validation instead of a silently empty result] Without the toDate < fromDate check, sending an impossible range (to before from) 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 clear 400, 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 400 conventionally signals — contrast with the 404s 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 now once] now is captured a single time before filtering starts, rather than calling new 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:

    1. Outer .map() — for each Availability document (one per calendar day), build a new object with a renamed availabilityId and a slots array that's itself the result of steps 2–3.

    2. Inner .filter() — keep only slots where !slot.isBooked (not taken) and new Date(slot.startTime) > now (still in the future) — both must be true.

    3. Inner .map() — rebuild each surviving slot with exactly three fields.

    4. Outer .filter() — after the outer .map() finishes, drop any day whose slots array ended up empty, so the response doesn't include clutter like { date: ..., slots: [] }.

  • [Date/Time — "only future" filtering] new Date(slot.startTime) > now works because JS automatically converts Date objects 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 only slotId/startTime/endTime — leaving out the raw slot's bookingId, which would otherwise reveal internal booking-record identifiers to an anonymous browsing visitor with no legitimate reason to see them. Same principle as LAWYER_FIELDS (Block 1), applied to a different data shape.

  • [SE] Final response again goes through the shared sendSuccess envelope.


2 views

About the author

No comments yet. Be the first to comment.