feat: prevent room scheduling conflicts #3

Merged
kdaniel2410 merged 2 commits from codex/booking-availability-guards into main 2026-06-25 11:14:03 +00:00
2 changed files with 160 additions and 10 deletions
Showing only changes of commit 44b34c850c - Show all commits
+13 -3
View File
@@ -1,7 +1,8 @@
import { z } from 'zod';
import { idSchema, optionalText, requiredText } from './shared.schema';
export const bookingCreateSchema = z.object({
export const bookingCreateSchema = z
.object({
clientId: requiredText('Client'),
roomId: requiredText('Room'),
serviceId: optionalText(80),
@@ -9,6 +10,15 @@ export const bookingCreateSchema = z.object({
endsAt: requiredText('End time', 40),
status: z.enum(['booked', 'confirmed', 'completed', 'cancelled']).default('booked'),
notes: optionalText(1000)
});
})
.superRefine((data, ctx) => {
if (data.startsAt && data.endsAt && data.endsAt <= data.startsAt) {
ctx.addIssue({
code: 'custom',
path: ['endsAt'],
message: 'End time must be after the start time.'
});
}
});
export const bookingEditSchema = bookingCreateSchema.extend(idSchema.shape);
export const bookingEditSchema = bookingCreateSchema.safeExtend(idSchema.shape);
+140
View File
@@ -0,0 +1,140 @@
import { and, asc, eq, gt, gte, inArray, isNull, lt, lte, ne } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { bookings, contracts, contractRooms, rooms } from '$lib/server/db/schema';
const rentableContractRoomTypes = ['private_office', 'coworking_desk'] as const;
export type BookingRoomConflict = {
id: string;
roomName: string;
startsAt: string;
endsAt: string;
};
export type ContractRoomConflict = {
contractId: string;
roomId: string;
roomName: string;
startDate: string;
endDate: string;
};
export function bookingIntervalsOverlap(
startsAt: string,
endsAt: string,
existingStartsAt: string,
existingEndsAt: string
) {
return startsAt < existingEndsAt && endsAt > existingStartsAt;
}
export function contractPeriodsOverlap(
startDate: string,
endDate: string,
existingStartDate: string,
existingEndDate: string
) {
return startDate <= existingEndDate && endDate >= existingStartDate;
}
export async function findBookingRoomConflict({
organizationId,
roomId,
startsAt,
endsAt,
status,
excludingBookingId
}: {
organizationId: string;
roomId: string;
startsAt: string;
endsAt: string;
status: string;
excludingBookingId?: string;
}): Promise<BookingRoomConflict | null> {
if (status === 'cancelled') return null;
const baseConditions = [
eq(bookings.organizationId, organizationId),
eq(bookings.roomId, roomId),
isNull(bookings.archivedAt),
ne(bookings.status, 'cancelled'),
lt(bookings.startsAt, endsAt),
gt(bookings.endsAt, startsAt)
];
const where = excludingBookingId
? and(...baseConditions, ne(bookings.id, excludingBookingId))
: and(...baseConditions);
const [conflict] = await db
.select({
id: bookings.id,
roomName: rooms.name,
startsAt: bookings.startsAt,
endsAt: bookings.endsAt
})
.from(bookings)
.innerJoin(rooms, eq(bookings.roomId, rooms.id))
.where(where)
.orderBy(asc(bookings.startsAt))
.limit(1);
return conflict ?? null;
}
export async function findActiveContractRoomConflict({
organizationId,
roomIds,
startDate,
endDate,
excludingContractId
}: {
organizationId: string;
roomIds: string[];
startDate: string;
endDate: string;
excludingContractId?: string;
}): Promise<ContractRoomConflict | null> {
const uniqueRoomIds = [...new Set(roomIds)];
if (uniqueRoomIds.length === 0) return null;
const baseConditions = [
eq(contracts.organizationId, organizationId),
eq(contracts.status, 'active'),
isNull(contracts.archivedAt),
inArray(contractRooms.roomId, uniqueRoomIds),
eq(rooms.organizationId, organizationId),
inArray(rooms.type, rentableContractRoomTypes),
isNull(rooms.archivedAt),
lte(contracts.startDate, endDate),
gte(contracts.endDate, startDate)
];
const where = excludingContractId
? and(...baseConditions, ne(contracts.id, excludingContractId))
: and(...baseConditions);
const [conflict] = await db
.select({
contractId: contracts.id,
roomId: contractRooms.roomId,
roomName: rooms.name,
startDate: contracts.startDate,
endDate: contracts.endDate
})
.from(contractRooms)
.innerJoin(contracts, eq(contractRooms.contractId, contracts.id))
.innerJoin(rooms, eq(contractRooms.roomId, rooms.id))
.where(where)
.orderBy(asc(contracts.startDate), asc(rooms.name))
.limit(1);
return conflict ?? null;
}
export function bookingConflictMessage(conflict: BookingRoomConflict) {
return `${conflict.roomName} is already booked from ${conflict.startsAt} to ${conflict.endsAt}.`;
}
export function contractConflictMessage(conflict: ContractRoomConflict) {
return `${conflict.roomName} is already assigned to an active contract from ${conflict.startDate} to ${conflict.endDate}.`;
}