diff --git a/api/routes/bookings.js b/api/routes/bookings.js index 65e7652..9d03d48 100644 --- a/api/routes/bookings.js +++ b/api/routes/bookings.js @@ -16,6 +16,30 @@ router.get('/', async function (req, res) { } }); +router.get('/cancel/:token', async function (req, res) { + const token = req.params.token; + if (!token || token.trim() === '') { + res.status(400).send('Token is required.'); + return; + } + try { + const booking = await mainModel.bookings.getByToken(token); + if (booking == null) { + res.status(404).send('No booking found with this token.'); + return; + } + + const resource = await mainModel.resources.getById(booking.resourceId); + if (resource != null) { + booking.resourceName = resource.name; + } + res.json(booking); + } catch (error) { + console.log(`Error trying to GET booking by cancellation token: ${error}`); + res.status(500).send('Unexpected error trying to get a booking.'); + } +}); + router.get('/:id', async function (req, res) { const id = checkId(req.params.id); if (id == null) { @@ -59,7 +83,7 @@ router.post('/', async function (req, res) { // Check for overlapping bookings const bookingStarts = moment(req.body.starts); const bookingEnds = moment(req.body.ends); - + if (!bookingStarts.isValid() || !bookingEnds.isValid()) { res.status(400).send('Invalid start or end date.'); return; @@ -81,7 +105,7 @@ router.post('/', async function (req, res) { for (const existingBooking of existingBookings) { const existingStarts = moment(existingBooking.starts); const existingEnds = moment(existingBooking.ends); - + // Two ranges overlap if: existingStarts < bookingEnds AND existingEnds > bookingStarts if (existingStarts.isBefore(bookingEnds) && existingEnds.isAfter(bookingStarts)) { overlappingCount++; @@ -101,14 +125,12 @@ router.post('/', async function (req, res) { const booking = await mainModel.bookings.getById(bookingId); // Get resource information for email (reuse resource we already fetched) - let resourceName = 'your booking'; if (resource) { - resourceName = resource.name; booking.resourceName = resource.name; } // Send confirmation email (don't fail booking creation if email fails) - const websiteBaseUrl = process.env.OPTIMISM_WEBSITE_BASE_URL ; + const websiteBaseUrl = process.env.OPTIMISM_WEBSITE_BASE_URL; const cancellationUrl = `${websiteBaseUrl}/cancel-booking/${booking.token}`; emailService.sendBookingConfirmationEmail(booking, cancellationUrl) .catch((error) => { @@ -184,9 +206,27 @@ router.post('/cancel/:token', async function (req, res) { res.status(400).send('This booking has already been cancelled.'); return; } - // Update booking to set cancelled = true + await mainModel.bookings.update({ id: booking.id, cancelled: true }); - res.status(200).json({ message: 'Booking cancelled successfully.', booking: { ...booking, cancelled: true } }); + const cancelledBooking = { ...booking, cancelled: true }; + + // Cancellation must succeed even if loading the resource or sending the + // notification email fails. + try { + const resource = await mainModel.resources.getById(booking.resourceId); + if (resource != null) { + cancelledBooking.resourceName = resource.name; + } + } catch (error) { + console.error('Failed to load resource for cancellation notification:', error); + } + + emailService.sendBookingCancellationNotificationEmail(cancelledBooking) + .catch((error) => { + console.error('Failed to send cancellation notification email:', error); + }); + + res.status(200).json({ message: 'Booking cancelled successfully.', booking: cancelledBooking }); } catch (error) { console.log(`Error trying to cancel booking: ${error}`); res.status(500).send('Unexpected error trying to cancel booking'); diff --git a/api/services/emailService.js b/api/services/emailService.js index 1a6913b..c88fbc1 100644 --- a/api/services/emailService.js +++ b/api/services/emailService.js @@ -101,7 +101,43 @@ Thank you for your booking!`; } } +/** + * Sends a cancellation notification to the organisation. + * @param {Object} booking - The cancelled booking details + * @returns {Promise} Promise that resolves when the notification is sent + */ +async function sendBookingCancellationNotificationEmail (booking) { + const resourceName = booking.resourceName || 'your booking'; + const startTime = moment(booking.starts).format('dddd, MMMM Do YYYY, h:mm a'); + const endTime = moment(booking.ends).format('dddd, MMMM Do YYYY, h:mm a'); + + const mailOptions = { + from: organisation_from_address, + to: organisation_notification_address, + subject: `Booking Cancellation - ${resourceName}`, + text: `A booking has been cancelled. + +Booking Details: +- Resource: ${resourceName} +- Name: ${booking.name} +- Email: ${booking.email} +- Start: ${startTime} +- End: ${endTime} +${booking.notes ? `- Notes: ${booking.notes}` : ''}` + }; + + try { + const info = await transporter.sendMail(mailOptions); + console.log('Cancellation notification email sent successfully:', info.messageId); + return info; + } catch (error) { + console.error('Error sending cancellation notification email:', error); + throw error; + } +} + module.exports = { - sendBookingConfirmationEmail + sendBookingConfirmationEmail, + sendBookingCancellationNotificationEmail }; diff --git a/api/test/integration/cancellation.js b/api/test/integration/cancellation.js new file mode 100644 index 0000000..3fc4dad --- /dev/null +++ b/api/test/integration/cancellation.js @@ -0,0 +1,60 @@ +/* eslint-disable no-undef */ +const expect = require('chai').expect; +const request = require('supertest'); +const app = require('../../app'); +const knex = require('../../db'); +const emailService = require('../../services/emailService'); + +const cancellationToken = 'cancellation-test-token'; +const originalCancellationNotifier = emailService.sendBookingCancellationNotificationEmail; +let cancellationNotificationCount = 0; + +// Cancellation email delivery is deliberately asynchronous. Stub it here so +// these tests exercise the booking endpoint without contacting an SMTP server. +before(function () { + emailService.sendBookingCancellationNotificationEmail = function () { + cancellationNotificationCount++; + return Promise.resolve({ messageId: 'test-message-id' }); + }; +}); + +after(function () { + emailService.sendBookingCancellationNotificationEmail = originalCancellationNotifier; +}); + +beforeEach(async function () { + cancellationNotificationCount = 0; + await knex.migrate.latest(); + await knex.migrate.rollback(); + await knex.migrate.latest(); + await knex.seed.run(); + await knex('bookings').where({ id: 1 }).update({ + token: cancellationToken, + cancelled: false + }); +}); + +describe('booking cancellation flow', function () { + it('does not cancel a booking when its cancellation link is opened', async function () { + const response = await request(app).get(`/api/bookings/cancel/${cancellationToken}`); + + expect(response.status).to.equal(200); + expect(response.body.token).to.equal(cancellationToken); + expect(Boolean(response.body.cancelled)).to.equal(false); + expect(cancellationNotificationCount).to.equal(0); + + const booking = await knex('bookings').where({ id: 1 }).first(); + expect(Boolean(booking.cancelled)).to.equal(false); + }); + + it('cancels the booking when cancellation is confirmed with POST', async function () { + const response = await request(app).post(`/api/bookings/cancel/${cancellationToken}`); + + expect(response.status).to.equal(200); + expect(Boolean(response.body.booking.cancelled)).to.equal(true); + expect(cancellationNotificationCount).to.equal(1); + + const booking = await knex('bookings').where({ id: 1 }).first(); + expect(Boolean(booking.cancelled)).to.equal(true); + }); +}); diff --git a/website/routes/cancel-booking.js b/website/routes/cancel-booking.js index 7e586e2..b0840d8 100644 --- a/website/routes/cancel-booking.js +++ b/website/routes/cancel-booking.js @@ -7,6 +7,28 @@ const apiUrl = settings.apiUrl; const router = express.Router(); module.exports = router; +/** + * Converts an API error into a message suitable for the cancellation page. + * + * @param {Error} error - The Axios error returned by the API + * @param {string} defaultMessage - The fallback message + * @returns {string} The message to display + */ +function apiErrorMessage (error, defaultMessage) { + if (!error.response) { + return defaultMessage; + } + if (error.response.status === 404) { + return 'No booking found with this cancellation link.'; + } + if (error.response.status === 400) { + return typeof error.response.data === 'string' + ? error.response.data + : 'This booking has already been cancelled or the link is invalid.'; + } + return typeof error.response.data === 'string' ? error.response.data : defaultMessage; +} + router.get('/cancel-booking/:token', function (req, res) { const token = req.params.token; if (!token || token.trim() === '') { @@ -15,10 +37,47 @@ router.get('/cancel-booking/:token', function (req, res) { }); } - const cancelUrl = `${apiUrl}/bookings/cancel/${token}`; + const encodedToken = encodeURIComponent(token); + const bookingUrl = `${apiUrl}/bookings/cancel/${encodedToken}`; - axios.post(cancelUrl) + axios.get(bookingUrl) .then(function (response) { + const booking = response.data; + if (booking.cancelled === true) { + return res.render('cancel-booking.html', { + success: false, + message: 'This booking has already been cancelled.' + }); + } + + res.render('cancel-booking.html', { + confirmation: true, + booking: booking, + cancellationUrl: `/cancel-booking/${encodedToken}` + }); + }) + .catch(function (error) { + console.log('Error loading booking for cancellation:', error); + res.render('cancel-booking.html', { + success: false, + message: apiErrorMessage(error, 'An error occurred while loading your booking.') + }); + }); +}); + +router.post('/cancel-booking/:token', function (req, res) { + const token = req.params.token; + if (!token || token.trim() === '') { + return res.render('error.html', { + safeErrorMessage: utilities.safeErrorMessage('Invalid cancellation link.') + }); + } + + const encodedToken = encodeURIComponent(token); + const cancelUrl = `${apiUrl}/bookings/cancel/${encodedToken}`; + + axios.post(cancelUrl) + .then(function () { res.render('cancel-booking.html', { success: true, message: 'Your booking has been cancelled successfully.' @@ -26,20 +85,9 @@ router.get('/cancel-booking/:token', function (req, res) { }) .catch(function (error) { console.log('Error cancelling booking:', error); - let errorMessage = 'An error occurred while cancelling your booking.'; - if (error.response) { - if (error.response.status === 404) { - errorMessage = 'No booking found with this cancellation link.'; - } else if (error.response.status === 400) { - errorMessage = error.response.data || 'This booking has already been cancelled or the link is invalid.'; - } else { - errorMessage = error.response.data || errorMessage; - } - } res.render('cancel-booking.html', { success: false, - message: errorMessage + message: apiErrorMessage(error, 'An error occurred while cancelling your booking.') }); }); }); - diff --git a/website/templates/cancel-booking.html b/website/templates/cancel-booking.html index a965368..7fc11f5 100644 --- a/website/templates/cancel-booking.html +++ b/website/templates/cancel-booking.html @@ -9,18 +9,51 @@
Are you sure you want to cancel this booking? This action cannot be undone.
+{{ message }}