Source: utilities/formValidation.mjs

/**
 * Shared express-validator support for server-rendered forms.
 *
 * Controllers define their own field rules and messages. This module adds the
 * checks common to every management form (action and record ID) and handles
 * the redirect back to the form when validation fails.
 * @module utilities/formValidation
 */

// eslint-disable-next-line no-unused-vars -- Referenced by JSDoc types.
import express from "express";
import { body, param, validationResult } from "express-validator";

/**
 * Feedback stored in the session for one form page until it is displayed.
 * @typedef {object} FormFeedback
 * @property {Record<string, string>} errors First error message for each invalid field.
 * @property {Record<string, string>} values Submitted values to refill the form with.
 * @property {"create"|"update"|"delete"} action Action that was submitted.
 */

/**
 * Checks whether the submitted action writes field data.
 * @param {express.Request} req Incoming request.
 * @returns {boolean} True for create and update actions.
 */
const isWrite = (req) => ["create", "update"].includes(req.body?.action);

/**
 * Rejects arrays and objects that the urlencoded parser can produce from
 * repeated or bracketed field names.
 * @param {unknown} value Submitted value.
 * @returns {boolean} True for a string or safe integer.
 */
const isScalar = (value) =>
  typeof value === "string" || Number.isSafeInteger(value);

/** Largest value for a signed MySQL INT ID column. */
const maxId = 2147483647;

/**
 * Builds the action and record ID checks shared by management forms.
 * The ID is required for update and delete, and checked whenever it is in the URL.
 * @returns {ValidationChain[]} Validation chains.
 */
const managementFields = () => [
  body("action")
    .isString()
    .withMessage("Select a valid form action.")
    .bail()
    .isIn(["create", "update", "delete"])
    .withMessage("Select a valid form action."),
  param("id")
    .if(
      (_value, { req }) =>
        req.params.id !== undefined ||
        ["update", "delete"].includes(req.body?.action),
    )
    .custom(isScalar)
    .withMessage("A valid record ID is required for update or delete.")
    .bail()
    .isInt({ min: 1, max: maxId, allow_leading_zeroes: false })
    .withMessage("Record ID must be a positive integer."),
];

/**
 * Works out the GET page to return to after a failed submission.
 * Keeps a valid record ID and the query string so the user returns to the same
 * record, filters and page; anything else falls back to the base path.
 * @param {express.Request} req Incoming request.
 * @param {string} base Base form path, such as "/activities".
 * @returns {string} Path and query string of the form page.
 * @throws {Error} When the request URL is not under the base path.
 */
const feedbackPath = (req, base) => {
  const url = new URL(req.originalUrl, "http://localhost");
  if (url.pathname !== base && !url.pathname.startsWith(`${base}/`)) {
    throw new Error("Validation used outside a supported form route.");
  }
  const pathname =
    /^\/[1-9]\d*$/.test(url.pathname.slice(base.length)) &&
    Number(url.pathname.slice(base.length + 1)) <= maxId
      ? url.pathname
      : url.pathname === base
        ? url.pathname
        : base;
  return `${pathname}${url.search}`;
};

/**
 * Creates middleware that stops invalid submissions before the controller runs.
 *
 * When validation has failed, the first error per field and the listed values
 * are saved in `req.session.formFeedback`, keyed by the form page URL. The user
 * is then redirected (303) to that page, where the `formFeedback` middleware
 * displays them once. Valid requests continue to the next handler.
 * @param {string} path Base form path, such as "/authenticate".
 * @param {string[]} valueFields Fields whose values are refilled after an error.
 *   Leave out passwords and other sensitive fields.
 * @returns {express.RequestHandler} Express middleware.
 */
export const rejectInvalidForm = (path, valueFields) => (req, res, next) => {
  const errors = validationResult(req);
  if (errors.isEmpty()) {
    return next();
  }
  if (!req.session) {
    return next(new Error("Form validation requires session middleware."));
  }
  const destination = feedbackPath(req, path);
  const messages = Object.fromEntries(
    errors
      .array({ onlyFirstError: true })
      .map((error) => [error.path, error.msg]),
  );
  // Only listed fields are stored, so passwords are never kept in the session.
  const values = Object.fromEntries(
    valueFields.map((field) => [
      field,
      isScalar(req.body?.[field]) ? String(req.body[field]) : "",
    ]),
  );
  req.session.formFeedback = {
    ...req.session.formFeedback,
    [destination]: {
      errors: messages,
      values,
      action: ["create", "update", "delete"].includes(req.body?.action)
        ? req.body.action
        : "create",
    },
  };
  // Save before redirecting so the next GET request can read the feedback.
  req.session.save((error) => {
    if (error) {
      return next(error);
    }
    res.redirect(303, `${destination}#form-validation`);
  });
};

/**
 * Builds the validation middleware for a create, update and delete form.
 *
 * Runs the shared action and ID checks, then the controller's field rules for
 * create and update only (delete needs just a valid ID), then
 * {@link rejectInvalidForm}.
 * @example
 * static formValidation = management("/activities", ["name"], [
 *   body("name").isString().withMessage("Name must be text."),
 * ]);
 * @param {string} path Base form path, such as "/activities".
 * @param {string[]} valueFields Fields whose values are refilled after an error.
 * @param {ValidationChain[]} fields Field rules for create and update.
 * @param {Record<string, string>} [aliases] Map of camelCase field names to the
 *   snake_case names some forms post, such as `{ userId: "user_id" }`.
 * @returns {express.RequestHandler[]} Middleware to place before the route handler.
 */
export const management = (path, valueFields, fields, aliases = {}) => [
  // Copy snake_case values into the camelCase fields the rules check.
  (req, _res, next) => {
    for (const [field, alias] of Object.entries(aliases)) {
      if (req.body?.[field] == null && req.body?.[alias] !== undefined) {
        req.body[field] = req.body[alias];
      }
    }
    next();
  },
  ...managementFields(),
  async (req, res, next) => {
    if (isWrite(req)) {
      for (const field of fields) {
        await field.run(req);
      }
    }
    return rejectInvalidForm(path, valueFields)(req, res, next);
  },
];