import express from "express";
import { BookingsModel } from "../models/BookingsModel.mjs";
import { UsersModel } from "../models/UsersModel.mjs";
import { SessionsModel } from "../models/SessionsModel.mjs";
import { LocationModel } from "../models/LocationModel.mjs";
import { ActivitiesModel } from "../models/ActivitiesModel.mjs";
import XMLBuilder from "fast-xml-builder";
import { body } from "express-validator";
import { management } from "../utilities/formValidation.mjs";
const xmlBuilder = new XMLBuilder({
ignoreAttributes: false,
format: true,
});
const bookingsDtd = `<!DOCTYPE gymBookings [
<!ELEMENT gymBookings (booking*)>
<!ELEMENT booking (id, userId, created, session)>
<!ELEMENT id (#PCDATA)>
<!ELEMENT userId (#PCDATA)>
<!ELEMENT created (#PCDATA)>
<!ELEMENT session (id, title, date, time, activity, trainer, location)>
<!ELEMENT title (#PCDATA)>
<!ELEMENT date (#PCDATA)>
<!ELEMENT time (#PCDATA)>
<!ELEMENT activity (name, description)>
<!ELEMENT name (#PCDATA)>
<!ELEMENT description (#PCDATA)>
<!ELEMENT trainer (firstName, lastName)>
<!ELEMENT firstName (#PCDATA)>
<!ELEMENT lastName (#PCDATA)>
<!ELEMENT location (name, street, suburb, postcode)>
<!ELEMENT street (#PCDATA)>
<!ELEMENT suburb (#PCDATA)>
<!ELEMENT postcode (#PCDATA)>
]>`;
/**
* Builds the bookings management URL while preserving the active filters.
* @param {express.Request} req Current request.
* @param {Object} [options] Result flags.
* @returns {string} Bookings page URL.
*/
const bookingPageUrl = (
req,
{ bookingDeleted = false, bookingCreated = false } = {},
) => {
const query = new URLSearchParams();
if (req.query.booking_user_id === "all") {
query.set("booking_user_id", "all");
} else {
const bookingUserId = Number(req.query.booking_user_id);
if (Number.isInteger(bookingUserId) && bookingUserId > 0) {
query.set("booking_user_id", String(bookingUserId));
}
}
if (req.query.booking_trainer_id === "all") {
query.set("booking_trainer_id", "all");
} else {
const bookingTrainerId = Number(req.query.booking_trainer_id);
if (Number.isInteger(bookingTrainerId) && bookingTrainerId > 0) {
query.set("booking_trainer_id", String(bookingTrainerId));
}
}
if (bookingDeleted) {
query.set("booking_deleted", "1");
}
if (bookingCreated) {
query.set("booking_created", "1");
}
const search = query.toString();
return search ? `/bookings?${search}` : "/bookings";
};
/**
* BookingsController handles the management, viewing, and exporting of gym bookings.
*/
export class BookingsController {
/** @type {express.Router} */
static routes = express.Router();
/**
* Validation for the booking management form. Field rules run for create and
* update only.
* @type {express.RequestHandler[]}
*/
static formValidation = management(
"/bookings",
["sessionId", "userId"],
[
body("sessionId")
.custom(
(value) => typeof value === "string" || Number.isSafeInteger(value),
)
.withMessage("Session must be a valid number.")
.bail()
.isInt({ min: 1, max: 2147483647, allow_leading_zeroes: false })
.withMessage("Session must be an integer between 1 and 2147483647."),
body("userId")
.custom(
(value) => typeof value === "string" || Number.isSafeInteger(value),
)
.withMessage("User must be a valid number.")
.bail()
.isInt({ min: 1, max: 2147483647, allow_leading_zeroes: false })
.withMessage("User must be an integer between 1 and 2147483647."),
],
{ sessionId: "session_id", userId: "user_id" },
);
static {
this.routes.get("/", this.viewBookingManagement);
this.routes.get("/export.xml", this.exportBookingsXml);
this.routes.get("/:id", this.viewBookingManagement);
this.routes.post("/", this.formValidation, this.handleBookingManagement);
this.routes.post("/:id", this.formValidation, this.handleBookingManagement);
}
/** @type {express.RequestHandler} */
static async exportBookingsXml(req, res, next) {
const authenticatedUser = req.authenticatedUser;
if (!authenticatedUser) {
return res.status(401).send("Authentication required.");
}
try {
const canManageBookings = ["admin", "trainer"].includes(
authenticatedUser.role,
);
const [bookings, sessions, locations, activities, users] =
await Promise.all([
canManageBookings
? BookingsModel.getAll()
: BookingsModel.getByUserId(authenticatedUser.id),
SessionsModel.getAll(),
LocationModel.getAll(),
ActivitiesModel.getAll(),
UsersModel.getAll(),
]);
const selectedUserId = canManageBookings
? Number(req.query.booking_user_id) || null
: Number(authenticatedUser.id);
const selectedTrainerId = canManageBookings
? Number(req.query.booking_trainer_id) || null
: null;
const selectedLocationId = Number(req.query.booking_location_id) || null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const endDate = new Date(today);
endDate.setDate(endDate.getDate() + 7);
const dateValue = (date) =>
[
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-");
const firstDateValue = dateValue(today);
const endDateValue = dateValue(endDate);
const ownBookingSessionIds = new Set();
if (authenticatedUser.role === "trainer") {
bookings.forEach((booking) => {
if (Number(booking.user_id) === Number(authenticatedUser.id)) {
ownBookingSessionIds.add(Number(booking.session_id));
}
});
}
const visibleSessions = sessions
.filter((session) => {
const sessionDate = String(session.date).slice(0, 10);
const matchesDate =
sessionDate >= firstDateValue && sessionDate < endDateValue;
const matchesLocation =
!selectedLocationId ||
Number(session.location_id) === selectedLocationId;
const matchesTrainer =
!selectedTrainerId ||
Number(session.trainer_id) === selectedTrainerId ||
ownBookingSessionIds.has(Number(session.id));
return matchesDate && matchesLocation && matchesTrainer;
})
.sort(
(left, right) =>
String(left.date).localeCompare(String(right.date)) ||
String(left.time).localeCompare(String(right.time)),
);
const sessionsById = new Map(
visibleSessions.map((session) => [Number(session.id), session]),
);
const selectedBookings = bookings
.filter(
(booking) =>
sessionsById.has(Number(booking.session_id)) &&
(!selectedUserId || Number(booking.user_id) === selectedUserId),
)
.sort((left, right) => {
const leftSession = sessionsById.get(Number(left.session_id));
const rightSession = sessionsById.get(Number(right.session_id));
return (
String(leftSession.date).localeCompare(String(rightSession.date)) ||
String(leftSession.time).localeCompare(String(rightSession.time))
);
});
const bookingRecords = selectedBookings.map((booking) => {
const session = sessionsById.get(Number(booking.session_id));
const location =
locations.find(
(item) => Number(item.id) === Number(session.location_id),
) ?? {};
const activity =
activities.find(
(item) => Number(item.id) === Number(session.activity_id),
) ?? {};
const trainer =
users.find(
(user) => Number(user.id) === Number(session.trainer_id),
) ?? {};
return {
id: String(booking.id),
userId: String(booking.user_id),
created: String(booking.created ?? ""),
session: {
id: String(session.id),
title: String(session.title ?? ""),
date: String(session.date).slice(0, 10),
time: String(session.time ?? "").slice(0, 8),
activity: {
name: String(activity.name ?? ""),
description: String(activity.description ?? ""),
},
trainer: {
firstName: String(trainer.first_name ?? ""),
lastName: String(trainer.last_name ?? ""),
},
location: {
name: String(location.name ?? ""),
street: String(location.street ?? ""),
suburb: String(location.suburb ?? ""),
postcode: String(location.postcode ?? ""),
},
},
};
});
const xml = xmlBuilder.build({
"?xml": { "@_version": "1.0", "@_encoding": "UTF-8" },
gymBookings: { booking: bookingRecords },
});
res.set({
"Content-Type": "application/xml; charset=utf-8",
"Content-Disposition": 'attachment; filename="gym-bookings.xml"',
});
const declarationEnd = xml.indexOf("?>") + 2;
const document = `${xml.slice(0, declarationEnd)}\n${bookingsDtd}\n${xml.slice(declarationEnd).trimStart()}`;
return res.send(document);
} catch (error) {
return next(error);
}
}
/**
* Renders the booking management page. Admins and trainers see all bookings;
* members see only their own. Loads the booking in the URL into the edit form.
* @type {express.RequestHandler}
*/
static viewBookingManagement(req, res) {
const canManageBookings = ["admin", "trainer"].includes(
req.authenticatedUser?.role,
);
const bookingsPromise = canManageBookings
? BookingsModel.getAll()
: req.authenticatedUser?.id
? BookingsModel.getByUserId(req.authenticatedUser.id)
: Promise.resolve([]);
return Promise.all([
bookingsPromise,
UsersModel.getAll(),
SessionsModel.getAll(),
LocationModel.getAll(),
ActivitiesModel.getAll(),
])
.then(([bookings, users, sessions, locations, activities]) => {
const selectedBooking =
bookings.find((booking) => String(booking.id) === req.params.id) ??
new BookingsModel(null, req.query.session_id ?? "", 0, "");
const availableLocationId =
Number(req.query.available_location_id) || null;
const bookingLocationId = Number(req.query.booking_location_id) || null;
const bookingUserIdParam = req.query.booking_user_id;
const bookingTrainerIdParam = req.query.booking_trainer_id;
const bookingUserId = canManageBookings
? bookingUserIdParam === undefined
? req.authenticatedUser?.role === "trainer" ||
bookingTrainerIdParam !== undefined
? null
: (req.authenticatedUser?.id ?? null)
: bookingUserIdParam === "all"
? null
: Number(bookingUserIdParam) || null
: (req.authenticatedUser?.id ?? null);
const bookingTrainerId = canManageBookings
? bookingTrainerIdParam === undefined
? req.authenticatedUser?.role === "trainer"
? (req.authenticatedUser.id ?? null)
: null
: bookingTrainerIdParam === "all"
? null
: Number(bookingTrainerIdParam) || null
: null;
const availableSessions = availableLocationId
? sessions.filter(
(session) => Number(session.location_id) === availableLocationId,
)
: sessions;
const bookingSessions = sessions.filter((session) => {
const matchesLocation =
!bookingLocationId ||
Number(session.location_id) === bookingLocationId;
// A trainer's own bookings stay visible even under sessions taught by other trainers.
const isOwnBooking =
req.authenticatedUser?.role === "trainer" &&
bookings.some(
(booking) =>
Number(booking.session_id) === Number(session.id) &&
Number(booking.user_id) === Number(req.authenticatedUser.id),
);
const matchesTrainer =
!bookingTrainerId ||
Number(session.trainer_id) === Number(bookingTrainerId) ||
isOwnBooking;
return matchesLocation && matchesTrainer;
});
const today = new Date();
today.setHours(0, 0, 0, 0);
const byTime = (left, right) =>
String(left.time).localeCompare(String(right.time));
const calendarDays = Array.from({ length: 7 }, (_, index) => {
const date = new Date(today);
date.setDate(today.getDate() + index);
const dateValue = [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-");
const daySessions = availableSessions
.filter(
(session) => String(session.date).slice(0, 10) === dateValue,
)
.sort(byTime);
return {
dateValue,
label: `${date.toLocaleDateString("en-AU", { weekday: "short" }).slice(0, 3)} ${date.getDate()} ${date.toLocaleDateString("en-AU", { month: "short" }).slice(0, 3)}`,
bookings: bookings
.filter((booking) =>
daySessions.some(
(session) => session.id === Number(booking.session_id),
),
)
.sort((left, right) =>
byTime(
daySessions.find(
(session) => session.id === Number(left.session_id),
),
daySessions.find(
(session) => session.id === Number(right.session_id),
),
),
),
sessions: daySessions,
};
});
const bookingCalendarDays = calendarDays
.map((day) => ({
...day,
sessions: bookingSessions
.filter(
(session) =>
String(session.date).slice(0, 10) === day.dateValue,
)
.sort(byTime),
}))
.map((day) => ({
...day,
bookings: bookings
.filter(
(booking) =>
day.sessions.some(
(session) => session.id === Number(booking.session_id),
) &&
(!bookingUserId ||
Number(booking.user_id) === Number(bookingUserId)),
)
.sort((left, right) =>
byTime(
day.sessions.find(
(session) => session.id === Number(left.session_id),
),
day.sessions.find(
(session) => session.id === Number(right.session_id),
),
),
),
}));
res.render("booking_management.ejs", {
bookings,
users,
sessions: availableSessions,
locations,
activities,
calendarDays,
bookingCalendarDays,
selectedBooking,
availableLocationId,
bookingLocationId,
bookingTrainerId,
bookingUserId,
canManageBookings,
bookingDeleted: req.query.booking_deleted === "1",
bookingCreated: req.query.booking_created === "1",
authenticatedUser: req.authenticatedUser,
role: "admin",
});
})
.catch((error) => {
console.error(error);
res.status(500).render("status.ejs", {
status: "Database Error",
message: "Bookings could not be loaded.",
});
});
}
/**
* Creates, updates or deletes a booking from the validated management form.
* @type {express.RequestHandler}
*/
static handleBookingManagement(req, res) {
const booking = new BookingsModel(
req.params.id ? Number(req.params.id) : null,
req.body.sessionId ?? req.body.session_id,
Number(req.body.userId ?? req.body.user_id ?? 0),
req.body.action === "create" ? new Date() : undefined,
);
if (req.body.action === "create") {
return BookingsModel.create(booking)
.then(() => res.redirect(bookingPageUrl(req, { bookingCreated: true })))
.catch((error) => {
console.error(error);
res.status(500).render("status.ejs", {
status: "Database Error",
message: "The booking could not be created.",
});
});
} else if (req.body.action === "update") {
return BookingsModel.update(booking)
.then((result) =>
result.affectedRows > 0
? res.redirect(bookingPageUrl(req))
: res.status(404).render("status.ejs", {
status: "Booking Update Failed",
message: "The booking could not be found.",
}),
)
.catch((error) => {
console.error(error);
res.status(500).render("status.ejs", {
status: "Database Error",
message: "The booking could not be updated.",
});
});
} else if (req.body.action === "delete") {
const deleteBooking =
req.authenticatedUser?.role === "trainer"
? BookingsModel.getById(booking.id)
.then((existingBooking) =>
SessionsModel.getById(existingBooking.session_id).then(
(session) => ({ existingBooking, session }),
),
)
.then(({ existingBooking, session }) => {
const ownsSession =
Number(session.trainer_id) ===
Number(req.authenticatedUser.id);
const ownsBooking =
Number(existingBooking.user_id) ===
Number(req.authenticatedUser.id);
if (!ownsSession && !ownsBooking) {
return res.status(403).render("status.ejs", {
status: "Booking Deletion Forbidden",
message:
"You can only delete bookings for your own sessions or your own bookings",
});
}
return BookingsModel.delete(booking.id);
})
: BookingsModel.delete(booking.id);
return deleteBooking
.then((result) =>
result == null
? undefined
: result.affectedRows > 0
? res.redirect(bookingPageUrl(req, { bookingDeleted: true }))
: res.status(404).render("status.ejs", {
status: "Booking Deletion Failed",
message: "The booking could not be found.",
}),
)
.catch((error) => {
console.error(error);
res.status(500).render("status.ejs", {
status: "Database Error",
message: "The booking could not be deleted.",
});
});
} else {
res.status(400).render("status.ejs", {
status: "Invalid Action",
message: "The form doesn't support this action.",
});
}
}
/** @type {express.RequestHandler} */
static async list(req, res, next) {
try {
res.json(await BookingsModel.getAll());
} catch (error) {
next(error);
}
}
/** @type {express.RequestHandler} */
static async getById(req, res, next) {
try {
res.json(await BookingsModel.getById(Number(req.params.id)));
} catch (error) {
next(error);
}
}
/** @type {express.RequestHandler} */
static async create(req, res, next) {
try {
const result = await BookingsModel.create(req.body);
res.status(result.duplicate ? 409 : 201).json(result);
} catch (error) {
next(error);
}
}
/** @type {express.RequestHandler} */
static async update(req, res, next) {
try {
res.json(
await BookingsModel.update({ ...req.body, id: Number(req.params.id) }),
);
} catch (error) {
next(error);
}
}
/** @type {express.RequestHandler} */
static async delete(req, res, next) {
try {
res.json(await BookingsModel.delete(Number(req.params.id)));
} catch (error) {
next(error);
}
}
}