Problem Statement
Shared spaces — the auditorium, the computer lab, the science labs, the sports hall, the seminar room — are booked by writing in a diary kept at the front desk, or by asking whoever booked it last. The result is the double booking, and the double booking is expensive: two classes arrive at the lab, one of them loses the period, and the teacher who loses it is whoever backs down first.
The specific failures:
- Two bookings for the same room at the same time. Nothing prevents it. The diary is a piece of paper and two people can write on it an hour apart.
- Nobody knows what is free. Finding a room for Thursday afternoon means checking the diary page for Thursday and reading every line.
- Bookings that need permission do not get it. The auditorium requires the principal's approval in theory. In practice it is booked and the approval is assumed.
- Setup time is invisible. A booking that runs 14:00–15:00 in the diary actually occupies the hall from 13:30 because of the chairs. The next booking at 15:00 walks into a room being cleared.
Proposed Solution
One Facility document per bookable space, with its bookings embedded. Embedding is the point, not an optimisation: it is what makes the overlap check enforceable in a single atomic write.
backend/models/Facility.js
Facility: name, code (unique), category (auditorium, laboratory, sports, classroom, library, seminar, other), building, floor, capacity, amenities[], openingTime / closingTime (with derived minute integers), bufferMinutes, requiresApproval, minBookingMinutes, maxBookingMinutes, maxAdvanceDays, status (active, maintenance, retired), notes, bookings[].
Booking subdocument: reference, requestedBy / requesterName, title, purpose, date, startTime / endTime and derived startMinute / endMinute, expectedAttendance, status (pending, approved, rejected, cancelled, completed), approvedBy, approvedAt, rejectionReason, cancelReason, setupNotes.
The invariant, and how it is enforced
No two active bookings on the same facility and date may overlap.
Unlike most capacity rules this one can be expressed as a single MongoDB conditional update, and it should be:
Facility.findOneAndUpdate(
{
_id: facilityId,
status: 'active',
bookings: {
$not: {
$elemMatch: {
date,
status: { $in: ['pending', 'approved'] },
startMinute: { $lt: guardedEnd },
endMinute: { $gt: guardedStart },
},
},
},
},
{ $push: { bookings: newBooking } },
{ new: true }
)
The filter says "this facility has no active booking on that date whose interval intersects mine". If two requests race, the second one's filter no longer matches and it gets a 409 — the clash is impossible rather than unlikely. A read-then-write version of this passes both checks before either writes, which is exactly the diary, reimplemented in JavaScript.
guardedStart and guardedEnd are the requested interval widened by the facility's bufferMinutes on each side, so the setup and clear-down time is part of the interval the database protects rather than a note somebody is supposed to read.
A pending booking blocks the slot. Holding the room while approval is decided is the behaviour people expect from a booking system, and the alternative — approving a request whose slot was taken while it sat in the queue — is worse.
The rest of the rules
- Bookings must sit inside the facility's opening hours, run for at least
minBookingMinutes and at most maxBookingMinutes, and start no more than maxAdvanceDays ahead.
- A facility in
maintenance accepts no new bookings, and the existing ones stay visible so somebody has to deal with them rather than discovering the closure on the day.
- Approval is only required where
requiresApproval is set. Everywhere else a booking is live on submission, because making a teacher wait for permission to use a spare classroom is how a booking system gets abandoned.
- Cancelling frees the interval immediately; the record stays, with the reason.
API — /api/facilities
| Method |
Path |
Who |
| POST |
/ |
admin — register a facility |
| GET |
/ |
any signed-in user — catalogue with filters |
| GET |
/:id |
any signed-in user |
| PATCH |
/:id |
admin |
| PATCH |
/:id/status |
admin |
| DELETE |
/:id |
admin — refused while future bookings exist |
| GET |
/availability |
any signed-in user — free windows for a date, across facilities |
| GET |
/:id/schedule |
any signed-in user — one facility, one date |
| POST |
/:id/bookings |
teacher / admin — atomic, clash-guarded |
| PATCH |
/:id/bookings/:bookingId/approve |
admin |
| PATCH |
/:id/bookings/:bookingId/reject |
admin |
| PATCH |
/:id/bookings/:bookingId/cancel |
requester / admin |
| GET |
/my-bookings |
any signed-in user |
| GET |
/stats |
admin — utilisation per facility |
Frontend
A /facilities page: pick a date, see every facility as a row with its booked intervals drawn along it, click a gap to book it. Admins get the approval queue and the facility register. The availability view is the reason anybody opens the page — "what is free on Thursday at 2" should be one screen, not a search.
Alternative Approaches
A separate Booking collection. More conventional, and it makes the clash check either a transaction or a unique index on a discretised time grid. The grid version forces every booking onto 30-minute boundaries; the transaction version needs a replica set, which this project does not assume. Embedding gives a genuine atomic guarantee against a plain mongod.
Store bookings against a room string on some other model. That is the diary again, with the same amount of enforcement.
Skip the buffer. Then the auditorium is double-booked in practice while the database says it is fine, which is the worst of both.
Affected Area
Mockups / Additional Context
The $not: { $elemMatch: ... } filter above is the whole feature. Everything else is a form around it.
Problem Statement
Shared spaces — the auditorium, the computer lab, the science labs, the sports hall, the seminar room — are booked by writing in a diary kept at the front desk, or by asking whoever booked it last. The result is the double booking, and the double booking is expensive: two classes arrive at the lab, one of them loses the period, and the teacher who loses it is whoever backs down first.
The specific failures:
Proposed Solution
One
Facilitydocument per bookable space, with its bookings embedded. Embedding is the point, not an optimisation: it is what makes the overlap check enforceable in a single atomic write.backend/models/Facility.jsFacility:
name,code(unique),category(auditorium,laboratory,sports,classroom,library,seminar,other),building,floor,capacity,amenities[],openingTime/closingTime(with derived minute integers),bufferMinutes,requiresApproval,minBookingMinutes,maxBookingMinutes,maxAdvanceDays,status(active,maintenance,retired),notes,bookings[].Booking subdocument:
reference,requestedBy/requesterName,title,purpose,date,startTime/endTimeand derivedstartMinute/endMinute,expectedAttendance,status(pending,approved,rejected,cancelled,completed),approvedBy,approvedAt,rejectionReason,cancelReason,setupNotes.The invariant, and how it is enforced
No two active bookings on the same facility and date may overlap.
Unlike most capacity rules this one can be expressed as a single MongoDB conditional update, and it should be:
The filter says "this facility has no active booking on that date whose interval intersects mine". If two requests race, the second one's filter no longer matches and it gets a
409— the clash is impossible rather than unlikely. A read-then-write version of this passes both checks before either writes, which is exactly the diary, reimplemented in JavaScript.guardedStartandguardedEndare the requested interval widened by the facility'sbufferMinuteson each side, so the setup and clear-down time is part of the interval the database protects rather than a note somebody is supposed to read.A pending booking blocks the slot. Holding the room while approval is decided is the behaviour people expect from a booking system, and the alternative — approving a request whose slot was taken while it sat in the queue — is worse.
The rest of the rules
minBookingMinutesand at mostmaxBookingMinutes, and start no more thanmaxAdvanceDaysahead.maintenanceaccepts no new bookings, and the existing ones stay visible so somebody has to deal with them rather than discovering the closure on the day.requiresApprovalis set. Everywhere else a booking is live on submission, because making a teacher wait for permission to use a spare classroom is how a booking system gets abandoned.API —
/api/facilities///:id/:id/:id/status/:id/availability/:id/schedule/:id/bookings/:id/bookings/:bookingId/approve/:id/bookings/:bookingId/reject/:id/bookings/:bookingId/cancel/my-bookings/statsFrontend
A
/facilitiespage: pick a date, see every facility as a row with its booked intervals drawn along it, click a gap to book it. Admins get the approval queue and the facility register. The availability view is the reason anybody opens the page — "what is free on Thursday at 2" should be one screen, not a search.Alternative Approaches
A separate
Bookingcollection. More conventional, and it makes the clash check either a transaction or a unique index on a discretised time grid. The grid version forces every booking onto 30-minute boundaries; the transaction version needs a replica set, which this project does not assume. Embedding gives a genuine atomic guarantee against a plainmongod.Store bookings against a room string on some other model. That is the diary again, with the same amount of enforcement.
Skip the buffer. Then the auditorium is double-booked in practice while the database says it is fine, which is the worst of both.
Affected Area
Mockups / Additional Context
The
$not: { $elemMatch: ... }filter above is the whole feature. Everything else is a form around it.