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(); 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 = { 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.'); } };