From 44b34c850c1b57dfa90b67262448d1644b2e688b Mon Sep 17 00:00:00 2001 From: Daniel Kirby Date: Thu, 25 Jun 2026 11:39:34 +0100 Subject: [PATCH 1/2] feat: add scheduling conflict helpers --- src/lib/schemas/bookings.schema.ts | 30 ++++--- src/lib/server/scheduling.ts | 140 +++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 src/lib/server/scheduling.ts diff --git a/src/lib/schemas/bookings.schema.ts b/src/lib/schemas/bookings.schema.ts index aa0b298..1016387 100644 --- a/src/lib/schemas/bookings.schema.ts +++ b/src/lib/schemas/bookings.schema.ts @@ -1,14 +1,24 @@ import { z } from 'zod'; import { idSchema, optionalText, requiredText } from './shared.schema'; -export const bookingCreateSchema = z.object({ - clientId: requiredText('Client'), - roomId: requiredText('Room'), - serviceId: optionalText(80), - startsAt: requiredText('Start time', 40), - endsAt: requiredText('End time', 40), - status: z.enum(['booked', 'confirmed', 'completed', 'cancelled']).default('booked'), - notes: optionalText(1000) -}); +export const bookingCreateSchema = z + .object({ + clientId: requiredText('Client'), + roomId: requiredText('Room'), + serviceId: optionalText(80), + startsAt: requiredText('Start time', 40), + 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); diff --git a/src/lib/server/scheduling.ts b/src/lib/server/scheduling.ts new file mode 100644 index 0000000..7b38096 --- /dev/null +++ b/src/lib/server/scheduling.ts @@ -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 { + 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 { + 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}.`; +} -- 2.52.0 From 17f36e637803f7d23656a3ec29114c7403d348c1 Mon Sep 17 00:00:00 2001 From: Daniel Kirby Date: Thu, 25 Jun 2026 11:39:44 +0100 Subject: [PATCH 2/2] feat: guard room availability writes --- .../clients/[id]/bookings/+page.server.ts | 28 ++++++++- .../clients/[id]/contracts/+page.server.ts | 61 +++++++++++++++++-- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/routes/dashboard/clients/[id]/bookings/+page.server.ts b/src/routes/dashboard/clients/[id]/bookings/+page.server.ts index 5a5f43f..76a75bb 100644 --- a/src/routes/dashboard/clients/[id]/bookings/+page.server.ts +++ b/src/routes/dashboard/clients/[id]/bookings/+page.server.ts @@ -1,9 +1,10 @@ import { and, asc, eq, isNull } from 'drizzle-orm'; -import { message, superValidate } from 'sveltekit-superforms/server'; +import { message, setError, superValidate } from 'sveltekit-superforms/server'; import { zod4 } from 'sveltekit-superforms/adapters'; import { db } from '$lib/server/db'; import { bookings, clients, rooms, services } from '$lib/server/db/schema'; import { loadOrganizationContext } from '$lib/server/organizations'; +import { bookingConflictMessage, findBookingRoomConflict } from '$lib/server/scheduling'; import { archiveSchema } from '$lib/schemas/shared.schema'; import { bookingCreateSchema, bookingEditSchema } from '$lib/schemas/bookings.schema'; import type { Actions, PageServerLoad } from './$types'; @@ -92,6 +93,15 @@ export const actions: Actions = { if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 }); + const conflict = await findBookingRoomConflict({ + organizationId: activeOrganizationId, + roomId: form.data.roomId, + startsAt: form.data.startsAt, + endsAt: form.data.endsAt, + status: form.data.status + }); + if (conflict) return setError(form, 'roomId', bookingConflictMessage(conflict)); + try { await db.insert(bookings).values({ id: crypto.randomUUID(), @@ -123,6 +133,16 @@ export const actions: Actions = { if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 }); + const conflict = await findBookingRoomConflict({ + organizationId: activeOrganizationId, + roomId: form.data.roomId, + startsAt: form.data.startsAt, + endsAt: form.data.endsAt, + status: form.data.status, + excludingBookingId: form.data.id + }); + if (conflict) return setError(form, 'roomId', bookingConflictMessage(conflict)); + try { await db .update(bookings) @@ -140,7 +160,8 @@ export const actions: Actions = { and( eq(bookings.id, form.data.id), eq(bookings.clientId, params.id), - eq(bookings.organizationId, activeOrganizationId) + eq(bookings.organizationId, activeOrganizationId), + isNull(bookings.archivedAt) ) ); } catch { @@ -165,7 +186,8 @@ export const actions: Actions = { and( eq(bookings.id, form.data.id), eq(bookings.clientId, params.id), - eq(bookings.organizationId, activeOrganizationId) + eq(bookings.organizationId, activeOrganizationId), + isNull(bookings.archivedAt) ) ); diff --git a/src/routes/dashboard/clients/[id]/contracts/+page.server.ts b/src/routes/dashboard/clients/[id]/contracts/+page.server.ts index 8605bfb..1bef80e 100644 --- a/src/routes/dashboard/clients/[id]/contracts/+page.server.ts +++ b/src/routes/dashboard/clients/[id]/contracts/+page.server.ts @@ -4,6 +4,7 @@ import { zod4 } from 'sveltekit-superforms/adapters'; import { db } from '$lib/server/db'; import { contracts, contractRooms, rooms, services } from '$lib/server/db/schema'; import { loadOrganizationContext } from '$lib/server/organizations'; +import { contractConflictMessage, findActiveContractRoomConflict } from '$lib/server/scheduling'; import { archiveSchema } from '$lib/schemas/shared.schema'; import { contractCreateSchema, @@ -263,6 +264,34 @@ export const actions: Actions = { return message(form, invalidSelection.message, { status: 400 }); } + const [existingContract] = await db + .select({ status: contracts.status }) + .from(contracts) + .where( + and( + eq(contracts.id, form.data.id), + eq(contracts.clientId, params.id), + eq(contracts.organizationId, activeOrganizationId), + isNull(contracts.archivedAt) + ) + ) + .limit(1); + + if (!existingContract) return message(form, 'Choose a valid contract.', { status: 400 }); + + if (existingContract.status === 'active') { + const conflict = await findActiveContractRoomConflict({ + organizationId: activeOrganizationId, + roomIds: form.data.roomIds, + startDate: form.data.startDate, + endDate: form.data.endDate, + excludingContractId: form.data.id + }); + if (conflict) { + return message(form, contractConflictMessage(conflict), { status: 400 }); + } + } + try { await db.transaction(async (tx) => { await tx @@ -280,7 +309,8 @@ export const actions: Actions = { and( eq(contracts.id, form.data.id), eq(contracts.clientId, params.id), - eq(contracts.organizationId, activeOrganizationId) + eq(contracts.organizationId, activeOrganizationId), + isNull(contracts.archivedAt) ) ); @@ -311,7 +341,11 @@ export const actions: Actions = { if (!form.valid) return message(form, 'Choose a valid contract action.', { status: 400 }); const [contract] = await db - .select({ status: contracts.status }) + .select({ + status: contracts.status, + startDate: contracts.startDate, + endDate: contracts.endDate + }) .from(contracts) .where( and( @@ -332,6 +366,23 @@ export const actions: Actions = { return message(form, 'That contract status change is not allowed.', { status: 400 }); } + if (form.data.targetStatus === 'active') { + const roomLinks = await db + .select({ roomId: contractRooms.roomId }) + .from(contractRooms) + .where(eq(contractRooms.contractId, form.data.id)); + const conflict = await findActiveContractRoomConflict({ + organizationId: activeOrganizationId, + roomIds: roomLinks.map((link) => link.roomId), + startDate: contract.startDate, + endDate: contract.endDate, + excludingContractId: form.data.id + }); + if (conflict) { + return message(form, contractConflictMessage(conflict), { status: 400 }); + } + } + await db .update(contracts) .set({ status: form.data.targetStatus, updatedAt: new Date() }) @@ -339,7 +390,8 @@ export const actions: Actions = { and( eq(contracts.id, form.data.id), eq(contracts.clientId, params.id), - eq(contracts.organizationId, activeOrganizationId) + eq(contracts.organizationId, activeOrganizationId), + isNull(contracts.archivedAt) ) ); @@ -361,7 +413,8 @@ export const actions: Actions = { and( eq(contracts.id, form.data.id), eq(contracts.clientId, params.id), - eq(contracts.organizationId, activeOrganizationId) + eq(contracts.organizationId, activeOrganizationId), + isNull(contracts.archivedAt) ) ); -- 2.52.0