Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

236 lines
7.5 KiB
TypeScript

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<IServiceCategory[]> {
return knexOrTrx<IServiceCategory>('service_categories').where({ tenant }).select('*');
}
export async function getServices(
knexOrTrx: Knex | Knex.Transaction,
tenant: string,
page: number = 1,
pageSize: number = 999,
options: ServiceListOptions = {}
): Promise<PaginatedServicesResponse> {
const offset = (page - 1) * pageSize;
type SortField = NonNullable<ServiceListOptions['sort']>;
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<SortField, 'asc' | 'desc'> = {
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<SortField, string> = {
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<IServicePrice>('service_prices').where({ tenant }).whereIn('service_id', serviceIds).select('*')
: [];
const pricesByService = allPrices.reduce<Record<string, IServicePrice[]>>((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<IService, 'service_id' | 'tenant'>;
export async function createService(
knexOrTrx: Knex | Knex.Transaction,
tenant: string,
serviceData: CreateServiceInput
): Promise<IService> {
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<IService>
): Promise<IService> {
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<void> {
await knexOrTrx('service_catalog').where({ tenant, service_id: serviceId }).del();
}
export async function getServiceTypesForSelection(
knexOrTrx: Knex | Knex.Transaction,
tenant: string
): Promise<Array<{ id: string; name: string; is_standard: boolean }>> {
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,
}));
}