Files
clearity/src/routes/dashboard/clients/[id]/contracts/+page.server.ts
T

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