Skip to content

[Feature]: Facility & room booking with database-enforced clash prevention #297

Description

@MOHITKOURAV01

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

  • New page
  • New component
  • Authentication (role-scoped approvals, requester-scoped cancellation)
  • Other: new Mongoose model, controller and route module

Mockups / Additional Context

The $not: { $elemMatch: ... } filter above is the whole feature. Everything else is a form around it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions