Source: controllers/LocationController.mjs

import express from "express";
import { LocationModel } from "../models/LocationModel.mjs";
import { UsersModel } from "../models/UsersModel.mjs";
import { AuthenticationController } from "./AuthenticationController.mjs";
import { body } from "express-validator";
import { management } from "../utilities/formValidation.mjs";

/** HTTP handlers for locations. */
export class LocationController {
  /** @type {express.Router} */
  static routes = express.Router();

  /**
   * Validation for the location management form. Field rules run for create
   * and update only; limits match the locations table columns.
   * @type {express.RequestHandler[]}
   */
  static formValidation = management(
    "/locations",
    ["name", "phone", "email", "street", "suburb", "postcode", "manager"],
    [
      body("name")
        .isString()
        .withMessage("Name must be text.")
        .bail()
        .trim()
        .isLength({ min: 1, max: 100 })
        .withMessage("Name must contain 1-100 characters."),
      body("phone")
        .isString()
        .withMessage("Phone must be text.")
        .bail()
        .trim()
        .isLength({ min: 1, max: 20 })
        .withMessage("Phone must contain 1-20 characters.")
        .bail()
        .matches(/^\+?[\d ().-]+$/)
        .withMessage("Phone must contain digits and standard phone separators.")
        .bail()
        .custom((value) => /\d/.test(value))
        .withMessage("Phone must contain digits."),
      body("email")
        .isString()
        .withMessage("Email must be text.")
        .bail()
        .trim()
        .isLength({ min: 1, max: 45 })
        .withMessage("Email must contain 1-45 characters.")
        .bail()
        .isEmail()
        .withMessage("Email must be valid."),
      body("street")
        .isString()
        .withMessage("Street must be text.")
        .bail()
        .trim()
        .isLength({ min: 1, max: 100 })
        .withMessage("Street must contain 1-100 characters."),
      body("suburb")
        .isString()
        .withMessage("Suburb must be text.")
        .bail()
        .trim()
        .isLength({ min: 1, max: 100 })
        .withMessage("Suburb must contain 1-100 characters."),
      body("postcode")
        .custom(
          (value) => typeof value === "string" || Number.isSafeInteger(value),
        )
        .withMessage("Postcode must be a valid number.")
        .bail()
        .isInt({ min: 0, max: 9999, allow_leading_zeroes: false })
        .withMessage("Postcode must be an integer between 0 and 9999."),
      body("manager")
        .custom(
          (value) => typeof value === "string" || Number.isSafeInteger(value),
        )
        .withMessage("Manager must be a valid number.")
        .bail()
        .isInt({ min: 1, max: 2147483647, allow_leading_zeroes: false })
        .withMessage("Manager must be an integer between 1 and 2147483647."),
      body("deleted")
        .optional()
        .custom(
          (value) => typeof value === "string" || Number.isSafeInteger(value),
        )
        .withMessage("Deleted must be a valid number.")
        .bail()
        .isInt({ min: 0, max: 1, allow_leading_zeroes: false })
        .withMessage("Deleted must be an integer between 0 and 1."),
    ],
  );

  static {
    this.routes.get(
      "/",
      AuthenticationController.restrict(["admin"]),
      this.viewLocationManagement,
    );

    this.routes.get("/sales", this.viewLocationSales);

    this.routes.get(
      "/:id",
      AuthenticationController.restrict(["admin"]),
      this.viewLocationManagement,
    );

    this.routes.post(
      "/",
      AuthenticationController.restrict(["admin"]),
      this.formValidation,
      this.handleLocationManagement,
    );

    this.routes.post(
      "/:id",
      AuthenticationController.restrict(["admin"]),
      this.formValidation,
      this.handleLocationManagement,
    );
  }

  /**
   * Renders the location management page with a filtered, sorted and paginated
   * location list. Loads the location in the URL into the edit form, even when it
   * is not on the current page.
   * @type {express.RequestHandler}
   */
  static viewLocationManagement(req, res) {
    const selectedLocationId = req.params.id;
    const selectedSearchTerm = String(req.query.search_term ?? "").trim();
    const selectedSortBy = Object.keys(LocationModel.SORTABLE_COLUMNS).includes(
      req.query.sort_by,
    )
      ? req.query.sort_by
      : "name";
    const selectedSortDir = req.query.sort_dir === "desc" ? "desc" : "asc";
    const pageSize = 7;
    const selectedPage = Math.max(1, Number(req.query.page) || 1);
    const locationsPromise = LocationModel.list({
      searchTerm: selectedSearchTerm,
      sortBy: selectedSortBy,
      sortDir: selectedSortDir,
      page: selectedPage,
      pageSize,
    });

    Promise.all([locationsPromise, UsersModel.getAll()])
      .then(async ([{ locations, total }, users]) => {
        const selectedLocation =
          locations.find(
            (location) => String(location.id) === selectedLocationId,
          ) ??
          (selectedLocationId
            ? await LocationModel.getById(selectedLocationId).catch(() => null)
            : null) ??
          new LocationModel(null, "", "", "", "", "", 0, 0, 0, 0);

        res.render("location_management.ejs", {
          locations,
          users,
          selectedLocation,
          selectedSearchTerm,
          selectedSortBy,
          selectedSortDir,
          selectedPage,
          totalPages: Math.max(1, Math.ceil(total / pageSize)),
          authenticatedUser: req.authenticatedUser ?? {},
          role: "admin",
        });
      })
      .catch((error) => {
        console.log(error);
        res.status(500).render("status.ejs", {
          status: "Database Error",
          message: "Locations could not be loaded.",
        });
      });
  }

  /** @type {express.RequestHandler} */
  static viewLocationList(req, res) {
    const selectedSearchTerm = String(req.query.search_term ?? "").trim();
    const selectedSortBy = Object.keys(LocationModel.SORTABLE_COLUMNS).includes(
      req.query.sort_by,
    )
      ? req.query.sort_by
      : "name";
    const selectedSortDir = req.query.sort_dir === "desc" ? "desc" : "asc";
    const loadLocations =
      req.query.sort_by || req.query.sort_dir
        ? LocationModel.list({
            searchTerm: selectedSearchTerm,
            sortBy: selectedSortBy,
            sortDir: selectedSortDir,
          }).then((result) => result.locations)
        : req.query.search_term
          ? LocationModel.getBySearch(req.query.search_term)
          : LocationModel.getAll();

    loadLocations
      .then((locations) => {
        res.render("location_list.ejs", {
          locations,
          searchTerm: selectedSearchTerm,
          selectedSortBy,
          selectedSortDir,
          authenticatedUser: req.authenticatedUser,
          role: req.authenticatedUser?.role ?? "",
        });
      })
      .catch((error) => {
        console.error(error);
        res.status(500).render("status.ejs", {
          status: "Database Error",
          message: "Locations could not be loaded.",
        });
      });
  }

  /** @type {express.RequestHandler} */
  static viewLocationSales(req, res) {
    res.status(501).render("status.ejs", {
      status: "Locations Unavailable",
      message: "Locations are not available yet.",
    });
  }

  /** @type {express.RequestHandler} */
  static viewLocationDetails(req, res) {
    LocationModel.getById(req.params.id)
      .then((location) => {
        res.render("location_details.ejs", {
          location,
          authenticatedUser: req.authenticatedUser,
          role: req.authenticatedUser?.role ?? "",
        });
      })
      .catch((error) => {
        console.error(error);
        res.status(404).render("status.ejs", {
          status: "Location not found",
          message: "Maybe your location ID is invalid?",
        });
      });
  }

  /**
   * Creates, updates or deletes a location from the validated management form.
   * @type {express.RequestHandler}
   */
  static handleLocationManagement(req, res) {
    const authenticatedUserId = Number(req.authenticatedUser?.id);
    if (!Number.isInteger(authenticatedUserId) || authenticatedUserId <= 0) {
      return res.status(401).render("status.ejs", {
        status: "Unauthenticated",
        message: "Please log in before managing locations.",
      });
    }

    const formData = req.body;
    const location = new LocationModel(
      req.params.id ? Number(req.params.id) : null,
      formData.name,
      formData.phone,
      formData.email,
      formData.street,
      formData.suburb,
      Number(formData.postcode ?? 0),
      Number(formData.manager ?? 0),
      Number(formData.deleted ?? 0),
      authenticatedUserId,
    );

    if (formData.action === "create") {
      LocationModel.create(location)
        .then(() => res.redirect(303, "/locations"))
        .catch((error) => {
          console.error(error);
          res.status(500).render("status.ejs", {
            status: "Database Error",
            message: "The location could not be created.",
          });
        });
    } else if (formData.action === "update") {
      LocationModel.update(location)
        .then(() => {
          res.redirect(303, "/locations");
        })
        .catch((error) => {
          console.error(error);
          res.status(500).render("status.ejs", {
            status: "Database Error",
            message: "The location could not be updated.",
          });
        });
    } else if (formData.action === "delete") {
      LocationModel.delete(location.id)
        .then((result) => {
          if (result.affectedRows > 0) {
            res.redirect(303, "/locations");
          } else {
            res.status(404).render("status.ejs", {
              status: "Location Deletion Failed",
              message: "The location could not be found.",
            });
          }
        })
        .catch((error) => {
          console.error(error);
          res.status(500).render("status.ejs", {
            status: "Database Error",
            message: "The location could not be deleted.",
          });
        });
    } else {
      res.status(400).render("status.ejs", {
        status: "Invalid Action",
        message: "The form doesn't support this action.",
      });
    }
  }
}