Compare commits
3 Commits
cac9d0e04d
...
24a9d53ccf
| Author | SHA1 | Date | |
|---|---|---|---|
| 24a9d53ccf | |||
| 17f36e6378 | |||
| 44b34c850c |
@@ -1,14 +1,24 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { idSchema, optionalText, requiredText } from './shared.schema';
|
import { idSchema, optionalText, requiredText } from './shared.schema';
|
||||||
|
|
||||||
export const bookingCreateSchema = z.object({
|
export const bookingCreateSchema = z
|
||||||
clientId: requiredText('Client'),
|
.object({
|
||||||
roomId: requiredText('Room'),
|
clientId: requiredText('Client'),
|
||||||
serviceId: optionalText(80),
|
roomId: requiredText('Room'),
|
||||||
startsAt: requiredText('Start time', 40),
|
serviceId: optionalText(80),
|
||||||
endsAt: requiredText('End time', 40),
|
startsAt: requiredText('Start time', 40),
|
||||||
status: z.enum(['booked', 'confirmed', 'completed', 'cancelled']).default('booked'),
|
endsAt: requiredText('End time', 40),
|
||||||
notes: optionalText(1000)
|
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);
|
||||||
|
|||||||
@@ -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}.`;
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { and, asc, eq, isNull } from 'drizzle-orm';
|
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 { zod4 } from 'sveltekit-superforms/adapters';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { bookings, clients, rooms, services } from '$lib/server/db/schema';
|
import { bookings, clients, rooms, services } from '$lib/server/db/schema';
|
||||||
import { loadOrganizationContext } from '$lib/server/organizations';
|
import { loadOrganizationContext } from '$lib/server/organizations';
|
||||||
|
import { bookingConflictMessage, findBookingRoomConflict } from '$lib/server/scheduling';
|
||||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||||
import { bookingCreateSchema, bookingEditSchema } from '$lib/schemas/bookings.schema';
|
import { bookingCreateSchema, bookingEditSchema } from '$lib/schemas/bookings.schema';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
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 });
|
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 {
|
try {
|
||||||
await db.insert(bookings).values({
|
await db.insert(bookings).values({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -123,6 +133,16 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
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 {
|
try {
|
||||||
await db
|
await db
|
||||||
.update(bookings)
|
.update(bookings)
|
||||||
@@ -140,7 +160,8 @@ export const actions: Actions = {
|
|||||||
and(
|
and(
|
||||||
eq(bookings.id, form.data.id),
|
eq(bookings.id, form.data.id),
|
||||||
eq(bookings.clientId, params.id),
|
eq(bookings.clientId, params.id),
|
||||||
eq(bookings.organizationId, activeOrganizationId)
|
eq(bookings.organizationId, activeOrganizationId),
|
||||||
|
isNull(bookings.archivedAt)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -165,7 +186,8 @@ export const actions: Actions = {
|
|||||||
and(
|
and(
|
||||||
eq(bookings.id, form.data.id),
|
eq(bookings.id, form.data.id),
|
||||||
eq(bookings.clientId, params.id),
|
eq(bookings.clientId, params.id),
|
||||||
eq(bookings.organizationId, activeOrganizationId)
|
eq(bookings.organizationId, activeOrganizationId),
|
||||||
|
isNull(bookings.archivedAt)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { zod4 } from 'sveltekit-superforms/adapters';
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { contracts, contractRooms, rooms, services } from '$lib/server/db/schema';
|
import { contracts, contractRooms, rooms, services } from '$lib/server/db/schema';
|
||||||
import { loadOrganizationContext } from '$lib/server/organizations';
|
import { loadOrganizationContext } from '$lib/server/organizations';
|
||||||
|
import { contractConflictMessage, findActiveContractRoomConflict } from '$lib/server/scheduling';
|
||||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||||
import {
|
import {
|
||||||
contractCreateSchema,
|
contractCreateSchema,
|
||||||
@@ -263,6 +264,34 @@ export const actions: Actions = {
|
|||||||
return message(form, invalidSelection.message, { status: 400 });
|
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 {
|
try {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx
|
await tx
|
||||||
@@ -280,7 +309,8 @@ export const actions: Actions = {
|
|||||||
and(
|
and(
|
||||||
eq(contracts.id, form.data.id),
|
eq(contracts.id, form.data.id),
|
||||||
eq(contracts.clientId, params.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 });
|
if (!form.valid) return message(form, 'Choose a valid contract action.', { status: 400 });
|
||||||
|
|
||||||
const [contract] = await db
|
const [contract] = await db
|
||||||
.select({ status: contracts.status })
|
.select({
|
||||||
|
status: contracts.status,
|
||||||
|
startDate: contracts.startDate,
|
||||||
|
endDate: contracts.endDate
|
||||||
|
})
|
||||||
.from(contracts)
|
.from(contracts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -332,6 +366,23 @@ export const actions: Actions = {
|
|||||||
return message(form, 'That contract status change is not allowed.', { status: 400 });
|
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
|
await db
|
||||||
.update(contracts)
|
.update(contracts)
|
||||||
.set({ status: form.data.targetStatus, updatedAt: new Date() })
|
.set({ status: form.data.targetStatus, updatedAt: new Date() })
|
||||||
@@ -339,7 +390,8 @@ export const actions: Actions = {
|
|||||||
and(
|
and(
|
||||||
eq(contracts.id, form.data.id),
|
eq(contracts.id, form.data.id),
|
||||||
eq(contracts.clientId, params.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(
|
and(
|
||||||
eq(contracts.id, form.data.id),
|
eq(contracts.id, form.data.id),
|
||||||
eq(contracts.clientId, params.id),
|
eq(contracts.clientId, params.id),
|
||||||
eq(contracts.organizationId, activeOrganizationId)
|
eq(contracts.organizationId, activeOrganizationId),
|
||||||
|
isNull(contracts.archivedAt)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-4
@@ -1,11 +1,8 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
// Generated by Wrangler by running `wrangler types` (hash: 61c64c9cd1c2465ff3a92bc5ac82d57d)
|
// Generated by Wrangler by running `wrangler types` (hash: 4f9fe9ddb2ea1e2c41e1be5910da8551)
|
||||||
// Runtime types generated with workerd@1.20260521.1 2026-05-23 nodejs_als
|
// Runtime types generated with workerd@1.20260521.1 2026-05-23 nodejs_als
|
||||||
interface __BaseEnv_Env {
|
interface __BaseEnv_Env {
|
||||||
ASSETS: Fetcher;
|
ASSETS: Fetcher;
|
||||||
DATABASE_URL: string;
|
|
||||||
ORIGIN: string;
|
|
||||||
BETTER_AUTH_SECRET: string;
|
|
||||||
}
|
}
|
||||||
declare namespace Cloudflare {
|
declare namespace Cloudflare {
|
||||||
interface Env extends __BaseEnv_Env {}
|
interface Env extends __BaseEnv_Env {}
|
||||||
|
|||||||
Reference in New Issue
Block a user