Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 47 additions & 7 deletions api/routes/bookings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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++;
Expand All @@ -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) => {
Expand Down Expand Up @@ -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');
Expand Down
38 changes: 37 additions & 1 deletion api/services/emailService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

60 changes: 60 additions & 0 deletions api/test/integration/cancellation.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
76 changes: 62 additions & 14 deletions website/routes/cancel-booking.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() === '') {
Expand All @@ -15,31 +37,57 @@ 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.'
});
})
.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.')
});
});
});

41 changes: 35 additions & 6 deletions website/templates/cancel-booking.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,51 @@ <h1>
Cancel Booking
</h1>
<h2>
<small class="text-muted">{% if success %}Cancellation Complete{% else %}Cancellation Error{% endif %}</small>
<small class="text-muted">
{% if confirmation %}Confirm Cancellation{% elif success %}Cancellation Complete{% else %}Cancellation Error{% endif %}
</small>
</h2>
</div>
</div>
<div class="container">

<div class="mock-block">
{% if success %}
{% if confirmation %}
<div class="alert alert-warning" role="alert">
<h3>Cancel this booking?</h3>
<p>Are you sure you want to cancel this booking? This action cannot be undone.</p>
</div>

{% if booking.resourceName or booking.starts or booking.ends %}
<dl class="row">
{% if booking.resourceName %}
<dt class="col-sm-3">Resource:</dt>
<dd class="col-sm-9">{{ booking.resourceName }}</dd>
{% endif %}
{% if booking.starts %}
<dt class="col-sm-3">Start:</dt>
<dd class="col-sm-9">{{ booking.starts }}</dd>
{% endif %}
{% if booking.ends %}
<dt class="col-sm-3">End:</dt>
<dd class="col-sm-9">{{ booking.ends }}</dd>
{% endif %}
</dl>
{% endif %}

<form method="post" action="{{ cancellationUrl }}">
<button type="submit" class="btn btn-warning">Yes, cancel this booking</button>
<a class="btn btn-secondary" href="/" role="button">Keep this booking</a>
</form>
{% elif success %}
<div class="alert alert-success" role="alert">
<h3>Booking Cancelled</h3>
<p>{{ message }}</p>
</div>

<a class="btn btn-primary" href="/select-a-resource" role="button">
Make another booking
</a>
{% else %}
<div class="alert alert-danger" role="alert">
<h3>Cancellation Failed</h3>
Expand All @@ -29,10 +62,6 @@ <h3>Cancellation Failed</h3>
{% endif %}
</div>

<a class="btn btn-primary" href="/select-a-resource" role="button">
Make another booking
</a>

</div> <!-- /container -->

</main>
Expand Down