diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 0065d2f..2904d01 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,10 +4,12 @@
-
-
-
-
+
+
+
+
+
+
@@ -129,7 +131,7 @@
1774643312599
-
+
@@ -195,7 +197,15 @@
1774710366017
-
+
+
+ 1774710670712
+
+
+
+ 1774710670712
+
+
@@ -210,6 +220,7 @@
-
+
+
\ No newline at end of file
diff --git a/apps/api/src/reservations/dto/update-reservation.dto.ts b/apps/api/src/reservations/dto/update-reservation.dto.ts
new file mode 100644
index 0000000..f0d03ff
--- /dev/null
+++ b/apps/api/src/reservations/dto/update-reservation.dto.ts
@@ -0,0 +1,15 @@
+import { IsString, IsDateString, IsOptional } from 'class-validator';
+
+export class UpdateReservationDto {
+ @IsDateString()
+ @IsOptional()
+ startTime?: string;
+
+ @IsDateString()
+ @IsOptional()
+ endTime?: string;
+
+ @IsString()
+ @IsOptional()
+ spaceId?: string;
+}
diff --git a/apps/api/src/reservations/reservations.controller.ts b/apps/api/src/reservations/reservations.controller.ts
index 9cbde9f..6515079 100644
--- a/apps/api/src/reservations/reservations.controller.ts
+++ b/apps/api/src/reservations/reservations.controller.ts
@@ -7,9 +7,11 @@ import {
Param,
UseGuards,
Req,
+ Patch,
} from '@nestjs/common';
import { ReservationsService } from './reservations.service';
import { CreateReservationDto } from './dto/create-reservation.dto';
+import { UpdateReservationDto } from './dto/update-reservation.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@Controller('reservations')
@@ -22,19 +24,24 @@ export class ReservationsController {
return this.reservationsService.create(req.user.id, createReservationDto);
}
+ @Get('my')
+ findAllMy(@Req() req) {
+ return this.reservationsService.findAllByUserId(req.user.id);
+ }
+
@Get('space/:spaceId')
findAllBySpaceId(@Param('spaceId') spaceId: string, @Req() req) {
// Optionally we could filter by date if passed as query param
return this.reservationsService.findAllBySpaceId(spaceId);
}
- @Delete(':id')
- cancel(@Param('id') id: string) {
- return this.reservationsService.cancel(id);
+ @Patch(':id')
+ update(@Param('id') id: string, @Req() req, @Body() updateReservationDto: UpdateReservationDto) {
+ return this.reservationsService.update(req.user.id, id, updateReservationDto);
}
- @Get('my')
- findAllMy(@Req() req) {
- return this.reservationsService.findAllByUserId(req.user.id);
+ @Delete(':id')
+ cancel(@Param('id') id: string, @Req() req) {
+ return this.reservationsService.cancel(req.user.id, id);
}
}
diff --git a/apps/api/src/reservations/reservations.service.ts b/apps/api/src/reservations/reservations.service.ts
index f4f39bf..10aa69d 100644
--- a/apps/api/src/reservations/reservations.service.ts
+++ b/apps/api/src/reservations/reservations.service.ts
@@ -1,7 +1,8 @@
-import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
+import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EventsGateway } from '../events/events.gateway';
import { CreateReservationDto } from './dto/create-reservation.dto';
+import { UpdateReservationDto } from './dto/update-reservation.dto';
import { SpaceType } from '@prisma/client';
@Injectable()
@@ -92,16 +93,36 @@ export class ReservationsService {
return reservation;
}
- async cancel(reservationId: string) {
+ async cancel(userId: string, reservationId: string) {
const reservation = await this.prisma.reservation.findUnique({
where: { id: reservationId },
- include: { space: true },
+ include: {
+ space: true,
+ user: {
+ select: {
+ role: true
+ }
+ }
+ },
});
if (!reservation) {
throw new NotFoundException(`Reservation with ID ${reservationId} not found`);
}
+ // Check if user exists to get role
+ const requestingUser = await this.prisma.user.findUnique({
+ where: { id: userId },
+ });
+
+ if (!requestingUser) {
+ throw new NotFoundException(`User with ID ${userId} not found`);
+ }
+
+ if (reservation.userId !== userId && requestingUser.role !== 'ADMIN') {
+ throw new ForbiddenException('You can only cancel your own reservations unless you are an administrator');
+ }
+
await this.prisma.reservation.delete({
where: { id: reservationId },
});
@@ -119,10 +140,136 @@ export class ReservationsService {
async findAllByUserId(userId: string) {
return this.prisma.reservation.findMany({
where: { userId },
- include: { space: true },
+ include: {
+ space: {
+ include: {
+ floor: {
+ include: {
+ building: true,
+ },
+ },
+ },
+ },
+ },
+ orderBy: {
+ startTime: 'desc',
+ },
});
}
+ async update(userId: string, id: string, updateReservationDto: UpdateReservationDto) {
+ const reservation = await this.prisma.reservation.findUnique({
+ where: { id },
+ });
+
+ if (!reservation) {
+ throw new NotFoundException(`Reservation with ID ${id} not found`);
+ }
+
+ if (reservation.userId !== userId) {
+ throw new ForbiddenException('You can only update your own reservations');
+ }
+
+ const spaceId = updateReservationDto.spaceId || reservation.spaceId;
+ const startTime = updateReservationDto.startTime || reservation.startTime;
+ const endTime = updateReservationDto.endTime || reservation.endTime;
+
+ const space = await this.prisma.space.findUnique({
+ where: { id: spaceId },
+ });
+
+ if (!space) {
+ throw new NotFoundException(`Space with ID ${spaceId} not found`);
+ }
+
+ const start = new Date(startTime);
+ const end = new Date(endTime);
+
+ // Validation
+ if (start >= end) {
+ throw new BadRequestException('Start time must be before end time');
+ }
+
+ // Rules
+ if (space.type === SpaceType.DESK) {
+ start.setHours(0, 0, 0, 0);
+ end.setHours(23, 59, 59, 999);
+ } else if (space.type === SpaceType.MEETING_ROOM) {
+ if (start.getHours() < 7 || (end.getHours() > 20 || (end.getHours() === 20 && end.getMinutes() > 0))) {
+ throw new BadRequestException('Meeting rooms can only be booked between 07:00 and 20:00');
+ }
+ if (start.getMinutes() % 15 !== 0 || end.getMinutes() % 15 !== 0) {
+ throw new BadRequestException('Meeting rooms can only be booked in 15-minute increments');
+ }
+ } else if (space.type === SpaceType.AMENITY) {
+ throw new BadRequestException('Amenities cannot be booked');
+ }
+
+ // Overlap check
+ const existing = await this.prisma.reservation.findFirst({
+ where: {
+ spaceId,
+ id: { not: id },
+ OR: [
+ { startTime: { lt: end }, endTime: { gt: start } },
+ ],
+ },
+ });
+
+ if (existing) {
+ throw new BadRequestException('Space is already booked for the selected time');
+ }
+
+ const updated = await this.prisma.reservation.update({
+ where: { id },
+ data: {
+ startTime: start,
+ endTime: end,
+ spaceId,
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ email: true,
+ role: true,
+ }
+ },
+ space: true
+ }
+ });
+
+ // Notify clients on the floor if space or time changed
+ if (reservation.spaceId !== updated.spaceId) {
+ // Notify old floor
+ const oldSpace = await this.prisma.space.findUnique({ where: { id: reservation.spaceId } });
+ if (oldSpace) {
+ this.eventsGateway.emitSpaceStatusChanged(oldSpace.floorId, reservation.spaceId, false);
+ }
+ // Notify new floor
+ this.eventsGateway.emitSpaceStatusChanged(space.floorId, spaceId, true, {
+ reservation: {
+ id: updated.id,
+ startTime: updated.startTime,
+ endTime: updated.endTime,
+ user: updated.user
+ }
+ });
+ } else {
+ // Just notify current floor
+ this.eventsGateway.emitSpaceStatusChanged(space.floorId, spaceId, true, {
+ reservation: {
+ id: updated.id,
+ startTime: updated.startTime,
+ endTime: updated.endTime,
+ user: updated.user
+ }
+ });
+ }
+
+ return updated;
+ }
+
async findAllBySpaceId(spaceId: string) {
return this.prisma.reservation.findMany({
where: { spaceId },
diff --git a/apps/web/package.json b/apps/web/package.json
index bbb9631..524114b 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -26,6 +26,7 @@
},
"type": "module",
"dependencies": {
+ "date-fns": "^4.1.0",
"import-meta-resolve": "^4.2.0",
"interactjs": "^1.10.27",
"socket.io-client": "^4.8.3"
diff --git a/apps/web/src/lib/components/BookingModal.svelte b/apps/web/src/lib/components/BookingModal.svelte
index b2e6fae..bbc59f8 100644
--- a/apps/web/src/lib/components/BookingModal.svelte
+++ b/apps/web/src/lib/components/BookingModal.svelte
@@ -1,7 +1,8 @@
+
+
+
+
+
My Bookings
+
Manage your upcoming and past reservations.
+
+
+
+ {#if loading && !reservations.length}
+
+ {:else if error}
+
+ {error}
+
+ {:else if reservations.length === 0}
+
+
+
No bookings found
+
You haven't made any reservations yet.
+
+ Go to dashboard to book a space
+
+
+ {:else}
+
+ {#each reservations as res}
+
+
+
+
+ {#if res.space.type === 'DESK'}
+
+ {:else}
+
+ {/if}
+
+
+
{res.space.name}
+
+
+
+ {res.space.floor.building.name}, Floor {res.space.floor.number}
+
+
+
+ {format(new Date(res.startTime), "MMM d, yyyy")}
+
+
+
+ {format(new Date(res.startTime), "HH:mm")} - {format(new Date(res.endTime), "HH:mm")}
+
+
+
+
+
+
+
+
+
+
+
+ {/each}
+
+ {/if}
+
+
+{#if editingReservation}
+
+
+
+
Edit Booking
+
+
+
+
+
+
Space
+
{editingReservation.space.name}
+
{editingReservation.space.floor.building.name}, Floor {editingReservation.space.floor.number}
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if editingReservation.space.type === 'DESK'}
+
+ Note: Desk bookings are full-day. Times will be automatically adjusted to encompass the entire day.
+
+ {/if}
+
+
+
+
+
+
+
+
+{/if}
diff --git a/package-lock.json b/package-lock.json
index c8601c4..8c0ac68 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -65,6 +65,7 @@
"apps/web": {
"version": "0.0.1",
"dependencies": {
+ "date-fns": "^4.1.0",
"import-meta-resolve": "^4.2.0",
"interactjs": "^1.10.27",
"socket.io-client": "^4.8.3"
@@ -5630,6 +5631,15 @@
"node": ">=4"
}
},
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",