-
Notifications
You must be signed in to change notification settings - Fork 55
Backend work for Smart Room Booking & Clash Detection #233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
|
|
||
| const { Room, RoomBooking, Event, User } = require('../models/schema'); | ||
|
|
||
| exports.createRoom = async (req, res) => { | ||
| try { | ||
| const { name, capacity, location, amenities } = req.body; | ||
| const room = new Room({ name, capacity, location, amenities }); | ||
| await room.save(); | ||
| res.status(201).json({ message: 'Room created', room }); | ||
| } catch (err) { | ||
| if (err.code === 11000) { | ||
| return res.status(409).json({ message: 'Room name already exists' }); | ||
| } | ||
| res.status(500).json({ message: 'Error creating room', error: err.message }); | ||
| } | ||
| }; | ||
|
|
||
|
|
||
| exports.getAllRooms = async (_req, res) => { | ||
| try { | ||
| const rooms = await Room.find({ is_active: true }); | ||
| res.json(rooms); | ||
| } catch (err) { | ||
| res.status(500).json({ message: 'Error fetching rooms' }); | ||
| } | ||
| }; | ||
|
|
||
|
|
||
| exports.bookRoom = async (req, res) => { | ||
| try { | ||
| const { roomId, eventId, date, startTime, endTime, purpose } = req.body; | ||
| // Check for clash | ||
| const clash = await RoomBooking.findOne({ | ||
| room: roomId, | ||
| status: { $in: ['Pending', 'Approved'] }, | ||
| $or: [ | ||
| { startTime: { $lt: endTime }, endTime: { $gt: startTime } }, | ||
| ], | ||
| }); | ||
|
Comment on lines
+31
to
+39
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clash detection is incorrect: it ignores booking date and accepts invalid time ranges. This can report clashes across different days and allows 💡 Proposed fix try {
const { roomId, eventId, date, startTime, endTime, purpose } = req.body;
+ const bookingDate = new Date(date);
+ const start = new Date(startTime);
+ const end = new Date(endTime);
+
+ if ([bookingDate, start, end].some(d => Number.isNaN(d.getTime()))) {
+ return res.status(400).json({ message: 'Invalid date/time format' });
+ }
+ if (start >= end) {
+ return res.status(400).json({ message: 'startTime must be before endTime' });
+ }
+
// Check for clash
const clash = await RoomBooking.findOne({
room: roomId,
+ date: bookingDate,
status: { $in: ['Pending', 'Approved'] },
- $or: [
- { startTime: { $lt: endTime }, endTime: { $gt: startTime } },
- ],
+ startTime: { $lt: end },
+ endTime: { $gt: start },
});🤖 Prompt for AI Agents |
||
| if (clash) { | ||
| return res.status(409).json({ message: 'Room clash detected', conflictingBooking: clash }); | ||
| } | ||
| const booking = new RoomBooking({ | ||
| room: roomId, | ||
| event: eventId, | ||
| date, | ||
| startTime, | ||
| endTime, | ||
| purpose, | ||
| bookedBy: req.user._id, | ||
| }); | ||
| await booking.save(); | ||
| res.status(201).json({ message: 'Room booked (pending approval)', booking }); | ||
| } catch (err) { | ||
| res.status(500).json({ message: 'Error booking room', error: err.message }); | ||
| } | ||
| }; | ||
|
|
||
|
|
||
| exports.getAvailability = async (req, res) => { | ||
| try { | ||
| const { date, roomId } = req.query; | ||
| const query = { date: new Date(date) }; | ||
| if (roomId) query.room = roomId; | ||
| const bookings = await RoomBooking.find(query).populate('room event bookedBy'); | ||
| res.json(bookings); | ||
|
Comment on lines
+65
to
+66
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid returning full Both endpoints currently populate full user objects, which can leak internal/sensitive fields unnecessarily. 💡 Proposed fix-const bookings = await RoomBooking.find(query).populate('room event bookedBy');
+const bookings = await RoomBooking.find(query).populate('room event').populate({
+ path: 'bookedBy',
+ select: '_id role personal_info.name personal_info.email',
+});Also applies to: 121-122 🤖 Prompt for AI Agents |
||
| } catch (err) { | ||
| res.status(500).json({ message: 'Error fetching availability' }); | ||
| } | ||
| }; | ||
|
|
||
|
|
||
| exports.updateBookingStatus = async (req, res) => { | ||
| try { | ||
| const { id } = req.params; | ||
| const { status } = req.body; | ||
| if (!['Approved', 'Rejected'].includes(status)) { | ||
| return res.status(400).json({ message: 'Invalid status' }); | ||
| } | ||
| const booking = await RoomBooking.findByIdAndUpdate( | ||
| id, | ||
| { status, reviewedBy: req.user._id, updated_at: new Date() }, | ||
| { new: true } | ||
| ); | ||
|
Comment on lines
+77
to
+84
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Approval should re-check for overlapping approved bookings before status update. Without a final overlap check on approve, edge/race scenarios can still end in double-approved slots. 💡 Proposed fix const { id } = req.params;
const { status } = req.body;
@@
+ const current = await RoomBooking.findById(id);
+ if (!current) return res.status(404).json({ message: 'Booking not found' });
+
+ if (status === 'Approved') {
+ const approvedConflict = await RoomBooking.findOne({
+ _id: { $ne: current._id },
+ room: current.room,
+ date: current.date,
+ status: 'Approved',
+ startTime: { $lt: current.endTime },
+ endTime: { $gt: current.startTime },
+ });
+ if (approvedConflict) {
+ return res.status(409).json({ message: 'Cannot approve due to room clash' });
+ }
+ }
+
const booking = await RoomBooking.findByIdAndUpdate(🤖 Prompt for AI Agents |
||
| if (!booking) return res.status(404).json({ message: 'Booking not found' }); | ||
| res.json({ message: 'Booking status updated', booking }); | ||
| } catch (err) { | ||
| res.status(500).json({ message: 'Error updating booking status' }); | ||
| } | ||
| }; | ||
|
|
||
|
|
||
| exports.cancelBooking = async (req, res) => { | ||
| try { | ||
| const { id } = req.params; | ||
| const booking = await RoomBooking.findById(id); | ||
| if (!booking) return res.status(404).json({ message: 'Booking not found' }); | ||
|
|
||
| if ( | ||
| String(booking.bookedBy) !== String(req.user._id) && | ||
| !['PRESIDENT', 'GENSEC_SCITECH', 'GENSEC_ACADEMIC', 'GENSEC_CULTURAL', 'GENSEC_SPORTS', 'CLUB_COORDINATOR'].includes(req.user.role) | ||
| ) { | ||
| return res.status(403).json({ message: 'Forbidden' }); | ||
| } | ||
| booking.status = 'Cancelled'; | ||
| booking.updated_at = new Date(); | ||
| await booking.save(); | ||
| res.json({ message: 'Booking cancelled', booking }); | ||
| } catch (err) { | ||
| res.status(500).json({ message: 'Error cancelling booking' }); | ||
| } | ||
| }; | ||
|
|
||
| exports.getBookings = async (req, res) => { | ||
| try { | ||
| const { roomId, date, status } = req.query; | ||
| const query = {}; | ||
| if (roomId) query.room = roomId; | ||
| if (date) query.date = new Date(date); | ||
| if (status) query.status = status; | ||
| const bookings = await RoomBooking.find(query).populate('room event bookedBy'); | ||
| res.json(bookings); | ||
| } catch (err) { | ||
| res.status(500).json({ message: 'Error fetching bookings' }); | ||
| } | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -645,6 +645,94 @@ const OrganizationalUnit = mongoose.model( | |
| ); | ||
| const Announcement = mongoose.model("Announcement", announcementSchema); | ||
|
|
||
| const roomSchema = new mongoose.Schema({ | ||
| name: { | ||
| type: String, | ||
| required: true, | ||
| unique: true, | ||
| }, | ||
| capacity: { | ||
| type: Number, | ||
| required: true, | ||
| }, | ||
| location: { | ||
| type: String, | ||
| required: true, | ||
| }, | ||
| amenities: [ | ||
| { | ||
| type: String, | ||
| }, | ||
| ], | ||
|
Comment on lines
+648
to
+666
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Room schema is missing required API fields ( The linked API contract expects these fields in room responses, but the model currently does not store them. 💡 Proposed fix const roomSchema = new mongoose.Schema({
+ room_id: {
+ type: String,
+ required: true,
+ unique: true,
+ index: true,
+ },
name: {
type: String,
required: true,
unique: true,
},
@@
amenities: [
@@
],
+ allowed_roles: [
+ {
+ type: String,
+ },
+ ],🤖 Prompt for AI Agents |
||
| is_active: { | ||
| type: Boolean, | ||
| default: true, | ||
| }, | ||
| created_at: { | ||
| type: Date, | ||
| default: Date.now, | ||
| }, | ||
| updated_at: { | ||
| type: Date, | ||
| default: Date.now, | ||
| }, | ||
| }); | ||
|
|
||
| const Room = mongoose.model("Room", roomSchema); | ||
|
|
||
| const roomBookingSchema = new mongoose.Schema({ | ||
| room: { | ||
| type: mongoose.Schema.Types.ObjectId, | ||
| ref: "Room", | ||
| required: true, | ||
| }, | ||
| event: { | ||
| type: mongoose.Schema.Types.ObjectId, | ||
| ref: "Event", | ||
| }, | ||
| date: { | ||
| type: Date, | ||
| required: true, | ||
| }, | ||
| startTime: { | ||
| type: Date, | ||
| required: true, | ||
| }, | ||
| endTime: { | ||
| type: Date, | ||
| required: true, | ||
| }, | ||
| purpose: { | ||
| type: String, | ||
| }, | ||
| bookedBy: { | ||
| type: mongoose.Schema.Types.ObjectId, | ||
| ref: "User", | ||
| required: true, | ||
| }, | ||
| status: { | ||
| type: String, | ||
| enum: ["Pending", "Approved", "Rejected", "Cancelled"], | ||
| default: "Pending", | ||
| }, | ||
| reviewedBy: { | ||
| type: mongoose.Schema.Types.ObjectId, | ||
| ref: "User", | ||
| }, | ||
| created_at: { | ||
| type: Date, | ||
| default: Date.now, | ||
| }, | ||
| updated_at: { | ||
| type: Date, | ||
| default: Date.now, | ||
| }, | ||
| }); | ||
|
|
||
| roomBookingSchema.index({ room: 1, date: 1, startTime: 1, endTime: 1 }); | ||
|
|
||
| const RoomBooking = mongoose.model("RoomBooking", roomBookingSchema); | ||
|
|
||
| module.exports = { | ||
| User, | ||
| Feedback, | ||
|
|
@@ -656,4 +744,6 @@ module.exports = { | |
| Position, | ||
| OrganizationalUnit, | ||
| Announcement, | ||
| Room, | ||
| RoomBooking, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| const express = require('express'); | ||
| const router = express.Router(); | ||
| const isAuthenticated = require('../middlewares/isAuthenticated'); | ||
| const authorizeRole = require('../middlewares/authorizeRole'); | ||
| const { ROLE_GROUPS, ROLES } = require('../utils/roles'); | ||
| const roomBookingController = require('../controllers/roomBookingController'); | ||
|
|
||
| // Create a new room (admin only) | ||
| router.post('/create-room', isAuthenticated, authorizeRole(ROLE_GROUPS.ADMIN), roomBookingController.createRoom); | ||
|
|
||
| // Get all rooms | ||
| router.get('/rooms', isAuthenticated, roomBookingController.getAllRooms); | ||
|
|
||
|
Comment on lines
+11
to
+13
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Route contract does not match the required room APIs. With the 💡 Proposed fix-// Get all rooms
-router.get('/rooms', isAuthenticated, roomBookingController.getAllRooms);
+// GET /api/rooms
+router.get('/', isAuthenticated, roomBookingController.getAllRooms);
+
+// GET /api/rooms/:room_id
+router.get('/:room_id', isAuthenticated, roomBookingController.getRoomById);Also applies to: 35-35 🤖 Prompt for AI Agents |
||
| // Book a room (admin only) | ||
| router.post('/book', isAuthenticated, authorizeRole(ROLE_GROUPS.ADMIN), roomBookingController.bookRoom); | ||
|
|
||
| // Get room availability | ||
| router.get('/availability', isAuthenticated, roomBookingController.getAvailability); | ||
|
|
||
| // Get bookings (filterable) | ||
| router.get('/bookings', isAuthenticated, roomBookingController.getBookings); | ||
|
|
||
| // Update booking status (approve/reject) | ||
| router.put('/bookings/:id/status', isAuthenticated, authorizeRole([ | ||
| ROLES.PRESIDENT, | ||
| ROLES.GENSEC_SCITECH, | ||
| ROLES.GENSEC_ACADEMIC, | ||
| ROLES.GENSEC_CULTURAL, | ||
| ROLES.GENSEC_SPORTS, | ||
| ]), roomBookingController.updateBookingStatus); | ||
|
|
||
| // Cancel a booking | ||
| router.delete('/bookings/:id', isAuthenticated, roomBookingController.cancelBooking); | ||
|
|
||
| module.exports = router; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,17 +2,34 @@ require("dotenv").config(); | |
| const mongoose = require("mongoose"); | ||
|
|
||
| const { | ||
| User, | ||
| Feedback, | ||
| Achievement, | ||
| UserSkill, | ||
| Skill, | ||
| Event, | ||
| PositionHolder, | ||
| Position, | ||
| OrganizationalUnit, | ||
| User, | ||
| Feedback, | ||
| Achievement, | ||
| UserSkill, | ||
| Skill, | ||
| Event, | ||
| PositionHolder, | ||
| Position, | ||
| OrganizationalUnit, | ||
| Room, | ||
| } = require("./models/schema"); | ||
|
|
||
| // Sample Rooms for Seeding | ||
| const sampleRooms = [ | ||
| { name: "LH-101", capacity: 60, location: "Academic Block 1, Ground Floor", amenities: ["Projector", "AC", "Whiteboard"] }, | ||
| { name: "LH-102", capacity: 60, location: "Academic Block 1, Ground Floor", amenities: ["Projector", "AC"] }, | ||
| { name: "Seminar Hall", capacity: 120, location: "Admin Block, 1st Floor", amenities: ["Projector", "Sound System", "AC"] }, | ||
| ]; | ||
|
|
||
| // Seeds sample rooms for testing room booking features. | ||
|
|
||
| const seedRooms = async () => { | ||
| console.log("Seeding sample rooms..."); | ||
| await Room.deleteMany({}); | ||
| await Room.insertMany(sampleRooms); | ||
| console.log("Sample rooms seeded!"); | ||
| }; | ||
|
Comment on lines
+26
to
+31
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This leaves the room booking flow without baseline room data after a fresh seed run. 💡 Proposed fix async function seedDB() {
try {
@@
await clearData();
+ await seedRooms();
await seedOrganizationalUnits();
await seedUsers();🤖 Prompt for AI Agents |
||
|
|
||
| // --- Data for Seeding --- | ||
|
|
||
| // Original club/committee data. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getAllRoomsdoes not return live occupancy/status fields.The current response returns only static room docs, so it does not satisfy the required live fields (
status,current_event,occupied_until) for room status APIs.💡 Proposed fix (shape)
exports.getAllRooms = async (_req, res) => { try { - const rooms = await Room.find({ is_active: true }); - res.json(rooms); + const now = new Date(); + const rooms = await Room.find({ is_active: true }).lean(); + const activeBookings = await RoomBooking.find({ + status: 'Approved', + startTime: { $lte: now }, + endTime: { $gte: now }, + }).populate('event', 'title').lean(); + + const activeByRoom = new Map(activeBookings.map(b => [String(b.room), b])); + const payload = rooms.map(room => { + const active = activeByRoom.get(String(room._id)); + return { + ...room, + status: active ? 'occupied' : 'vacant', + current_event: active?.event?.title ?? null, + occupied_until: active?.endTime ?? null, + }; + }); + res.json(payload); } catch (err) { res.status(500).json({ message: 'Error fetching rooms' }); } };🤖 Prompt for AI Agents