424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
|
|
import { message, setError, superValidate } from 'sveltekit-superforms/server';
|
|
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,
|
|
contractEditSchema,
|
|
contractTransitionSchema
|
|
} from '$lib/schemas/contracts.schema';
|
|
import type { Actions, PageServerLoad } from './$types';
|
|
|
|
async function loadOptions(organizationId: string) {
|
|
const [roomRows, serviceRows] = await Promise.all([
|
|
db
|
|
.select({
|
|
id: rooms.id,
|
|
name: rooms.name,
|
|
type: rooms.type,
|
|
pricePerMonthGbp: rooms.pricePerMonthGbp
|
|
})
|
|
.from(rooms)
|
|
.where(
|
|
and(
|
|
eq(rooms.organizationId, organizationId),
|
|
inArray(rooms.type, ['private_office', 'coworking_desk']),
|
|
isNull(rooms.archivedAt)
|
|
)
|
|
)
|
|
.orderBy(asc(rooms.name)),
|
|
db
|
|
.select({ id: services.id, name: services.name, priceGbp: services.priceGbp })
|
|
.from(services)
|
|
.where(and(eq(services.organizationId, organizationId), isNull(services.archivedAt)))
|
|
.orderBy(asc(services.name))
|
|
]);
|
|
|
|
return {
|
|
rooms: [
|
|
...roomRows.map((room) => ({
|
|
label: `${room.name} · ${room.type === 'private_office' ? 'Private office' : 'Coworking desk'}`,
|
|
value: room.id,
|
|
licenseFeeGbp: room.type === 'private_office' ? (room.pricePerMonthGbp ?? 0) : 0,
|
|
depositGbp: room.type === 'private_office' ? (room.pricePerMonthGbp ?? 0) * 2 : 0
|
|
}))
|
|
],
|
|
services: [
|
|
{ label: 'No service', value: '' },
|
|
...serviceRows.map((service) => ({
|
|
label: service.name,
|
|
value: service.id,
|
|
licenseFeeGbp: service.priceGbp
|
|
}))
|
|
]
|
|
};
|
|
}
|
|
|
|
export const load: PageServerLoad = async ({ locals, params }) => {
|
|
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
|
const records = await db
|
|
.select({
|
|
contract: contracts,
|
|
serviceName: services.name
|
|
})
|
|
.from(contracts)
|
|
.leftJoin(services, eq(contracts.serviceId, services.id))
|
|
.where(
|
|
and(
|
|
eq(contracts.clientId, params.id),
|
|
eq(contracts.organizationId, activeOrganizationId),
|
|
isNull(contracts.archivedAt)
|
|
)
|
|
)
|
|
.orderBy(asc(contracts.startDate));
|
|
const contractIds = records.map((record) => record.contract.id);
|
|
const roomLinks =
|
|
contractIds.length > 0
|
|
? await db
|
|
.select({
|
|
contractId: contractRooms.contractId,
|
|
roomId: rooms.id,
|
|
roomName: rooms.name
|
|
})
|
|
.from(contractRooms)
|
|
.innerJoin(rooms, eq(contractRooms.roomId, rooms.id))
|
|
.where(inArray(contractRooms.contractId, contractIds))
|
|
.orderBy(asc(rooms.name))
|
|
: [];
|
|
const roomsByContract = new Map<string, { id: string; name: string }[]>();
|
|
for (const link of roomLinks) {
|
|
const existing = roomsByContract.get(link.contractId) ?? [];
|
|
existing.push({ id: link.roomId, name: link.roomName });
|
|
roomsByContract.set(link.contractId, existing);
|
|
}
|
|
|
|
return {
|
|
records: records.map((record) => {
|
|
const linkedRooms = roomsByContract.get(record.contract.id) ?? [];
|
|
return {
|
|
...record.contract,
|
|
roomName: linkedRooms.map((room) => room.name).join(', '),
|
|
roomNames: linkedRooms.map((room) => room.name),
|
|
roomIds: linkedRooms.map((room) => room.id),
|
|
serviceName: record.serviceName,
|
|
formValues: {
|
|
id: record.contract.id,
|
|
clientId: record.contract.clientId ?? '',
|
|
roomIds: linkedRooms.map((room) => room.id),
|
|
serviceId: record.contract.serviceId ?? '',
|
|
licenseFeeGbp: record.contract.licenseFeeGbp,
|
|
depositGbp: record.contract.depositGbp,
|
|
startDate: record.contract.startDate ?? '',
|
|
endDate: record.contract.endDate ?? ''
|
|
}
|
|
};
|
|
}),
|
|
options: await loadOptions(activeOrganizationId),
|
|
createForm: await superValidate(
|
|
{ clientId: params.id, roomIds: [] },
|
|
zod4(contractCreateSchema),
|
|
{
|
|
id: 'contracts-create',
|
|
errors: false
|
|
}
|
|
),
|
|
editForm: await superValidate(zod4(contractEditSchema), {
|
|
id: 'contracts-edit',
|
|
errors: false
|
|
}),
|
|
archiveForm: await superValidate(zod4(archiveSchema), {
|
|
id: 'contracts-archive',
|
|
errors: false
|
|
}),
|
|
transitionForm: await superValidate(zod4(contractTransitionSchema), {
|
|
id: 'contracts-transition',
|
|
errors: false
|
|
})
|
|
};
|
|
};
|
|
|
|
async function selectionError(
|
|
organizationId: string,
|
|
roomIds: string[],
|
|
serviceId: string
|
|
): Promise<{ field: 'roomIds' | 'serviceId'; message: string } | null> {
|
|
if (roomIds.length > 0) {
|
|
const uniqueRoomIds = [...new Set(roomIds)];
|
|
const validRooms = await db
|
|
.select({ id: rooms.id })
|
|
.from(rooms)
|
|
.where(
|
|
and(
|
|
inArray(rooms.id, uniqueRoomIds),
|
|
eq(rooms.organizationId, organizationId),
|
|
inArray(rooms.type, ['private_office', 'coworking_desk']),
|
|
isNull(rooms.archivedAt)
|
|
)
|
|
);
|
|
|
|
if (validRooms.length !== uniqueRoomIds.length) {
|
|
return {
|
|
field: 'roomIds',
|
|
message: 'Select a valid private office or coworking desk.'
|
|
};
|
|
}
|
|
}
|
|
|
|
if (serviceId) {
|
|
const [service] = await db
|
|
.select({ id: services.id })
|
|
.from(services)
|
|
.where(
|
|
and(
|
|
eq(services.id, serviceId),
|
|
eq(services.organizationId, organizationId),
|
|
isNull(services.archivedAt)
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
if (!service) return { field: 'serviceId', message: 'Select a valid service.' };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export const actions: Actions = {
|
|
create: async ({ locals, params, request }) => {
|
|
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
|
const formData = await request.formData();
|
|
formData.set('clientId', params.id);
|
|
const form = await superValidate(formData, zod4(contractCreateSchema), {
|
|
id: 'contracts-create'
|
|
});
|
|
|
|
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
|
const invalidSelection = await selectionError(
|
|
activeOrganizationId,
|
|
form.data.roomIds,
|
|
form.data.serviceId
|
|
);
|
|
if (invalidSelection) {
|
|
if (invalidSelection.field === 'serviceId') {
|
|
return setError(form, invalidSelection.field, invalidSelection.message);
|
|
}
|
|
return message(form, invalidSelection.message, { status: 400 });
|
|
}
|
|
|
|
try {
|
|
const contractId = crypto.randomUUID();
|
|
await db.transaction(async (tx) => {
|
|
await tx.insert(contracts).values({
|
|
id: contractId,
|
|
organizationId: activeOrganizationId,
|
|
clientId: form.data.clientId,
|
|
serviceId: form.data.serviceId || null,
|
|
licenseFeeGbp: form.data.licenseFeeGbp,
|
|
depositGbp: form.data.depositGbp,
|
|
startDate: form.data.startDate,
|
|
endDate: form.data.endDate,
|
|
status: 'draft',
|
|
updatedAt: new Date(),
|
|
createdAt: new Date()
|
|
});
|
|
|
|
const uniqueRoomIds = [...new Set(form.data.roomIds)];
|
|
if (uniqueRoomIds.length > 0) {
|
|
await tx.insert(contractRooms).values(
|
|
uniqueRoomIds.map((roomId) => ({
|
|
contractId,
|
|
roomId
|
|
}))
|
|
);
|
|
}
|
|
});
|
|
} catch {
|
|
return message(form, 'Unable to create contract.', { status: 400 });
|
|
}
|
|
|
|
return message(form, 'Contract created.');
|
|
},
|
|
|
|
edit: async ({ locals, params, request }) => {
|
|
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
|
const formData = await request.formData();
|
|
formData.set('clientId', params.id);
|
|
const form = await superValidate(formData, zod4(contractEditSchema), {
|
|
id: 'contracts-edit'
|
|
});
|
|
|
|
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
|
const invalidSelection = await selectionError(
|
|
activeOrganizationId,
|
|
form.data.roomIds,
|
|
form.data.serviceId
|
|
);
|
|
if (invalidSelection) {
|
|
if (invalidSelection.field === 'serviceId') {
|
|
return setError(form, invalidSelection.field, invalidSelection.message);
|
|
}
|
|
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
|
|
.update(contracts)
|
|
.set({
|
|
clientId: form.data.clientId,
|
|
serviceId: form.data.serviceId || null,
|
|
licenseFeeGbp: form.data.licenseFeeGbp,
|
|
depositGbp: form.data.depositGbp,
|
|
startDate: form.data.startDate,
|
|
endDate: form.data.endDate,
|
|
updatedAt: new Date()
|
|
})
|
|
.where(
|
|
and(
|
|
eq(contracts.id, form.data.id),
|
|
eq(contracts.clientId, params.id),
|
|
eq(contracts.organizationId, activeOrganizationId),
|
|
isNull(contracts.archivedAt)
|
|
)
|
|
);
|
|
|
|
await tx.delete(contractRooms).where(eq(contractRooms.contractId, form.data.id));
|
|
const uniqueRoomIds = [...new Set(form.data.roomIds)];
|
|
if (uniqueRoomIds.length > 0) {
|
|
await tx.insert(contractRooms).values(
|
|
uniqueRoomIds.map((roomId) => ({
|
|
contractId: form.data.id,
|
|
roomId
|
|
}))
|
|
);
|
|
}
|
|
});
|
|
} catch {
|
|
return message(form, 'Unable to update contract.', { status: 400 });
|
|
}
|
|
|
|
return message(form, 'Contract updated.');
|
|
},
|
|
|
|
transition: async ({ locals, params, request }) => {
|
|
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
|
const form = await superValidate(await request.formData(), zod4(contractTransitionSchema), {
|
|
id: 'contracts-transition'
|
|
});
|
|
|
|
if (!form.valid) return message(form, 'Choose a valid contract action.', { status: 400 });
|
|
|
|
const [contract] = await db
|
|
.select({
|
|
status: contracts.status,
|
|
startDate: contracts.startDate,
|
|
endDate: contracts.endDate
|
|
})
|
|
.from(contracts)
|
|
.where(
|
|
and(
|
|
eq(contracts.id, form.data.id),
|
|
eq(contracts.clientId, params.id),
|
|
eq(contracts.organizationId, activeOrganizationId),
|
|
isNull(contracts.archivedAt)
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
const allowedTransitions: Record<string, readonly string[]> = {
|
|
draft: ['active', 'void'],
|
|
active: ['expired']
|
|
};
|
|
|
|
if (!contract || !allowedTransitions[contract.status]?.includes(form.data.targetStatus)) {
|
|
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() })
|
|
.where(
|
|
and(
|
|
eq(contracts.id, form.data.id),
|
|
eq(contracts.clientId, params.id),
|
|
eq(contracts.organizationId, activeOrganizationId),
|
|
isNull(contracts.archivedAt)
|
|
)
|
|
);
|
|
|
|
return message(form, `Contract marked ${form.data.targetStatus}.`);
|
|
},
|
|
|
|
archive: async ({ locals, params, request }) => {
|
|
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
|
const form = await superValidate(await request.formData(), zod4(archiveSchema), {
|
|
id: 'contracts-archive'
|
|
});
|
|
|
|
if (!form.valid) return message(form, 'Contract id is required.', { status: 400 });
|
|
|
|
await db
|
|
.update(contracts)
|
|
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(contracts.id, form.data.id),
|
|
eq(contracts.clientId, params.id),
|
|
eq(contracts.organizationId, activeOrganizationId),
|
|
isNull(contracts.archivedAt)
|
|
)
|
|
);
|
|
|
|
return message(form, 'Contract archived.');
|
|
}
|
|
};
|