import type { Knex } from 'knex'; import { v4 as uuidv4 } from 'uuid'; import type { IService, IServicePrice, IServiceCategory, ISO8601String } from '@alga-psa/types'; export type ServiceListOptions = { search?: string; item_kind?: 'service' | 'product' | 'any'; is_active?: boolean; billing_method?: 'fixed' | 'hourly' | 'usage' | 'per_unit'; category_id?: string | null; custom_service_type_id?: string; sort?: 'service_name' | 'billing_method' | 'default_rate'; order?: 'asc' | 'desc'; }; export type PaginatedServicesResponse = { services: IService[]; totalCount: number; page: number; pageSize: number; }; export async function getServiceCategories( knexOrTrx: Knex | Knex.Transaction, tenant: string ): Promise { return knexOrTrx('service_categories').where({ tenant }).select('*'); } export async function getServices( knexOrTrx: Knex | Knex.Transaction, tenant: string, page: number = 1, pageSize: number = 999, options: ServiceListOptions = {} ): Promise { const offset = (page - 1) * pageSize; type SortField = NonNullable; const sortFields: SortField[] = ['service_name', 'billing_method', 'default_rate']; const sortField: SortField = sortFields.includes(options.sort as SortField) ? (options.sort as SortField) : 'service_name'; const defaultOrderForSort: Record = { service_name: 'asc', billing_method: 'asc', default_rate: 'asc' }; const sortOrder: 'asc' | 'desc' = options.order === 'asc' || options.order === 'desc' ? options.order : defaultOrderForSort[sortField]; const sanitizedOptions: ServiceListOptions & { sort: SortField; order: 'asc' | 'desc' } = { search: options.search?.trim() ? options.search.trim() : undefined, item_kind: options.item_kind ?? 'service', is_active: options.is_active, billing_method: options.billing_method, category_id: options.category_id, custom_service_type_id: options.custom_service_type_id, sort: sortField, order: sortOrder }; const supportsServiceBillingMetadata = sanitizedOptions.item_kind === 'service'; const applyFilters = (query: Knex.QueryBuilder) => { if (sanitizedOptions.item_kind && sanitizedOptions.item_kind !== 'any') { query.where('sc.item_kind', sanitizedOptions.item_kind); } if (sanitizedOptions.is_active !== undefined) { query.where('sc.is_active', sanitizedOptions.is_active); } if (sanitizedOptions.billing_method && supportsServiceBillingMetadata) { query.where('sc.billing_method', sanitizedOptions.billing_method); } if (sanitizedOptions.custom_service_type_id) { query.where('sc.custom_service_type_id', sanitizedOptions.custom_service_type_id); } if (sanitizedOptions.category_id !== undefined) { if (sanitizedOptions.category_id === null) { query.whereNull('sc.category_id'); } else { query.where('sc.category_id', sanitizedOptions.category_id); } } if (sanitizedOptions.search) { const term = `%${sanitizedOptions.search}%`; query.andWhere((builder) => { builder.whereILike('sc.service_name', term).orWhereILike('sc.description', term).orWhereILike('sc.sku', term); }); } return query; }; const sortColumnMap: Record = { service_name: 'sc.service_name', billing_method: 'sc.billing_method', default_rate: 'sc.default_rate' }; const effectiveSortField: SortField = sanitizedOptions.sort === 'billing_method' && !supportsServiceBillingMetadata ? 'service_name' : sanitizedOptions.sort; const baseQuery = knexOrTrx('service_catalog as sc').where({ 'sc.tenant': tenant }); const countResult = await applyFilters(baseQuery.clone()).count('sc.service_id as count').first(); const totalCount = parseInt((countResult?.count as string) || '0', 10); const servicesData = await applyFilters( baseQuery .clone() .leftJoin('service_types as st', function () { this.on('sc.custom_service_type_id', '=', 'st.id').andOn('sc.tenant', '=', 'st.tenant'); }) .select( 'sc.service_id', 'sc.service_name', 'sc.custom_service_type_id', 'sc.billing_method', knexOrTrx.raw('CAST(sc.default_rate AS FLOAT) as default_rate'), 'sc.unit_of_measure', 'sc.category_id', 'sc.tenant', 'sc.description', 'sc.item_kind', 'sc.is_active', 'sc.sku', knexOrTrx.raw('CAST(sc.cost AS FLOAT) as cost'), 'sc.cost_currency', 'sc.vendor', 'sc.manufacturer', 'sc.product_category', 'sc.is_license', 'sc.license_term', 'sc.license_billing_cadence', 'sc.tax_rate_id', 'st.name as service_type_name' ) ) .orderBy(sortColumnMap[effectiveSortField], sanitizedOptions.order) .modify((queryBuilder) => { if (effectiveSortField !== 'service_name') { queryBuilder.orderBy('sc.service_name', 'asc'); } queryBuilder.orderBy('sc.service_id', 'asc'); }) .limit(pageSize) .offset(offset); const serviceIds = servicesData.map((s: { service_id: string }) => s.service_id); const allPrices = serviceIds.length ? await knexOrTrx('service_prices').where({ tenant }).whereIn('service_id', serviceIds).select('*') : []; const pricesByService = allPrices.reduce>((acc, price) => { if (!acc[price.service_id]) acc[price.service_id] = []; acc[price.service_id].push(price); return acc; }, {}); const services: IService[] = servicesData.map((row: any) => ({ ...row, prices: pricesByService[row.service_id] ?? [] })) as IService[]; return { services, totalCount, page, pageSize }; } export type CreateServiceInput = Omit; export async function createService( knexOrTrx: Knex | Knex.Transaction, tenant: string, serviceData: CreateServiceInput ): Promise { const service_id = uuidv4(); const now = (knexOrTrx as any).fn?.now ? (knexOrTrx as any).fn.now() : new Date().toISOString(); const [created] = await knexOrTrx('service_catalog') .insert({ ...serviceData, service_id, tenant, item_kind: (serviceData as any).item_kind ?? 'service', created_at: now, updated_at: now }) .returning('*'); return created as IService; } export async function updateService( knexOrTrx: Knex | Knex.Transaction, tenant: string, serviceId: string, serviceData: Partial ): Promise { const now = (knexOrTrx as any).fn?.now ? (knexOrTrx as any).fn.now() : new Date().toISOString(); const [updated] = await knexOrTrx('service_catalog') .where({ tenant, service_id: serviceId }) .update({ ...serviceData, updated_at: now }) .returning('*'); return updated as IService; } export async function deleteService( knexOrTrx: Knex | Knex.Transaction, tenant: string, serviceId: string ): Promise { await knexOrTrx('service_catalog').where({ tenant, service_id: serviceId }).del(); } export async function getServiceTypesForSelection( knexOrTrx: Knex | Knex.Transaction, tenant: string ): Promise> { const rows = await knexOrTrx('service_types') .where({ tenant, is_active: true }) .select('id', 'name') .orderBy('name', 'asc'); return rows.map((r: any) => ({ ...r, is_standard: false, })); }