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

603 lines
18 KiB
TypeScript

import type { IDataObject } from 'n8n-workflow';
export const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export interface AlgaApiError {
statusCode?: number;
code: string;
message: string;
details?: unknown;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function compactObject(input: IDataObject): IDataObject {
return Object.entries(input).reduce((acc, [key, value]) => {
if (value === undefined || value === null || value === '') {
return acc;
}
if (Array.isArray(value) && value.length === 0) {
return acc;
}
acc[key] = value;
return acc;
}, {} as IDataObject);
}
export function parseCsvList(value: unknown): string[] {
if (!value) {
return [];
}
return String(value)
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
export function parseTags(value: unknown): string[] | undefined {
const tags = parseCsvList(value);
return tags.length > 0 ? tags : undefined;
}
export function parseAttributes(value: unknown): IDataObject | undefined {
if (!value) {
return undefined;
}
if (isObject(value)) {
return value as IDataObject;
}
const raw = String(value).trim();
if (!raw) {
return undefined;
}
const parsed = JSON.parse(raw);
if (!isObject(parsed)) {
throw new Error('Attributes must be a JSON object');
}
return parsed as IDataObject;
}
export function ensureNonEmpty(value: unknown, fieldName: string): string {
const normalized = String(value ?? '').trim();
if (!normalized) {
throw new Error(`${fieldName} is required`);
}
return normalized;
}
export function ensureUuid(value: unknown, fieldName: string): string {
const normalized = ensureNonEmpty(value, fieldName);
if (!UUID_REGEX.test(normalized)) {
throw new Error(`${fieldName} must be a valid UUID`);
}
return normalized;
}
export function extractResourceLocatorValue(value: unknown): string {
if (typeof value === 'string') {
return value.trim();
}
if (isObject(value) && 'value' in value) {
return String(value.value ?? '').trim();
}
return '';
}
export function buildTicketCreatePayload(input: {
title: string;
clientId: string;
boardId: string;
statusId: string;
priorityId: string;
additionalFields?: IDataObject;
}): IDataObject {
const additional = input.additionalFields ?? {};
const payload: IDataObject = {
title: input.title,
client_id: input.clientId,
board_id: input.boardId,
status_id: input.statusId,
priority_id: input.priorityId,
location_id: additional.location_id,
contact_name_id: additional.contact_name_id,
category_id: additional.category_id,
subcategory_id: additional.subcategory_id,
assigned_to: additional.assigned_to,
url: additional.url,
attributes: parseAttributes(additional.attributes),
tags: parseTags(additional.tags),
};
return compactObject(payload);
}
export function buildTicketUpdatePayload(additionalFields: IDataObject = {}): IDataObject {
const payload: IDataObject = {
title: additionalFields.title,
client_id: additionalFields.client_id,
board_id: additionalFields.board_id,
status_id: additionalFields.status_id,
priority_id: additionalFields.priority_id,
location_id: additionalFields.location_id,
contact_name_id: additionalFields.contact_name_id,
category_id: additionalFields.category_id,
subcategory_id: additionalFields.subcategory_id,
assigned_to: additionalFields.assigned_to,
url: additionalFields.url,
attributes: parseAttributes(additionalFields.attributes),
tags: parseTags(additionalFields.tags),
};
return compactObject(payload);
}
function normalizeOptionalUuid(value: unknown, fieldName: string): string | undefined {
if (value === undefined || value === null || String(value).trim() === '') {
return undefined;
}
return ensureUuid(value, fieldName);
}
function normalizeJsonInput(value: unknown, fieldName: string): unknown {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value !== 'string') {
return value;
}
const raw = value.trim();
if (!raw) {
return undefined;
}
try {
return JSON.parse(raw);
} catch {
throw new Error(`${fieldName} must be valid JSON`);
}
}
export function parseContactPhoneNumbers(value: unknown): IDataObject[] | undefined {
const parsed = normalizeJsonInput(value, 'phone_numbers');
if (parsed === undefined) {
return undefined;
}
if (!Array.isArray(parsed)) {
throw new Error('phone_numbers must be a JSON array');
}
return parsed.map((entry, index) => {
if (!isObject(entry)) {
throw new Error(`phone_numbers[${index}] must be an object`);
}
const phoneNumber = ensureNonEmpty(entry.phone_number, `phone_numbers[${index}].phone_number`);
const contactPhoneNumberId = normalizeOptionalUuid(
entry.contact_phone_number_id,
`phone_numbers[${index}].contact_phone_number_id`,
);
const canonicalType =
entry.canonical_type === undefined || entry.canonical_type === null
? undefined
: ensureNonEmpty(entry.canonical_type, `phone_numbers[${index}].canonical_type`);
const customType =
entry.custom_type === undefined || entry.custom_type === null
? undefined
: ensureNonEmpty(entry.custom_type, `phone_numbers[${index}].custom_type`);
if (entry.is_default !== undefined && typeof entry.is_default !== 'boolean') {
throw new Error(`phone_numbers[${index}].is_default must be a boolean`);
}
if (
entry.display_order !== undefined &&
(!Number.isInteger(entry.display_order) || Number(entry.display_order) < 0)
) {
throw new Error(`phone_numbers[${index}].display_order must be a non-negative integer`);
}
const isDefault =
entry.is_default === undefined ? undefined : (entry.is_default as boolean);
const displayOrder =
entry.display_order === undefined ? undefined : Number(entry.display_order);
return compactObject({
contact_phone_number_id: contactPhoneNumberId,
phone_number: phoneNumber,
canonical_type: canonicalType,
custom_type: customType,
is_default: isDefault,
display_order: displayOrder,
});
});
}
export function parseContactEmailAddresses(value: unknown): IDataObject[] | undefined {
const parsed = normalizeJsonInput(value, 'additional_email_addresses');
if (parsed === undefined) {
return undefined;
}
if (!Array.isArray(parsed)) {
throw new Error('additional_email_addresses must be a JSON array');
}
return parsed.map((entry, index) => {
if (!isObject(entry)) {
throw new Error(`additional_email_addresses[${index}] must be an object`);
}
const emailAddress = ensureNonEmpty(
entry.email_address,
`additional_email_addresses[${index}].email_address`,
);
const additionalEmailId = normalizeOptionalUuid(
entry.contact_additional_email_address_id,
`additional_email_addresses[${index}].contact_additional_email_address_id`,
);
const canonicalType =
entry.canonical_type === undefined || entry.canonical_type === null
? undefined
: ensureNonEmpty(entry.canonical_type, `additional_email_addresses[${index}].canonical_type`);
const customType =
entry.custom_type === undefined || entry.custom_type === null
? undefined
: ensureNonEmpty(entry.custom_type, `additional_email_addresses[${index}].custom_type`);
if (
entry.display_order !== undefined &&
(!Number.isInteger(entry.display_order) || Number(entry.display_order) < 0)
) {
throw new Error(
`additional_email_addresses[${index}].display_order must be a non-negative integer`,
);
}
const displayOrder =
entry.display_order === undefined ? undefined : Number(entry.display_order);
return compactObject({
contact_additional_email_address_id: additionalEmailId,
email_address: emailAddress,
canonical_type: canonicalType,
custom_type: customType,
display_order: displayOrder,
});
});
}
export function buildContactCreatePayload(input: {
fullName: string;
additionalFields?: IDataObject;
}): IDataObject {
const additional = input.additionalFields ?? {};
const clientId = normalizeOptionalUuid(additional.client_id, 'client_id');
return compactObject({
full_name: input.fullName,
email: additional.email,
primary_email_canonical_type: additional.primary_email_canonical_type,
primary_email_custom_type: additional.primary_email_custom_type,
primary_email_custom_type_id: normalizeOptionalUuid(
additional.primary_email_custom_type_id,
'primary_email_custom_type_id',
),
additional_email_addresses: parseContactEmailAddresses(additional.additional_email_addresses),
client_id: clientId,
role: additional.role,
notes: additional.notes,
is_inactive: additional.is_inactive,
phone_numbers: parseContactPhoneNumbers(additional.phone_numbers),
});
}
export function buildContactUpdatePayload(additionalFields: IDataObject = {}): IDataObject {
const clientId = normalizeOptionalUuid(additionalFields.client_id, 'client_id');
return compactObject({
full_name: additionalFields.full_name,
email: additionalFields.email,
primary_email_canonical_type: additionalFields.primary_email_canonical_type,
primary_email_custom_type: additionalFields.primary_email_custom_type,
primary_email_custom_type_id: normalizeOptionalUuid(
additionalFields.primary_email_custom_type_id,
'primary_email_custom_type_id',
),
additional_email_addresses: parseContactEmailAddresses(additionalFields.additional_email_addresses),
client_id: clientId,
role: additionalFields.role,
notes: additionalFields.notes,
is_inactive: additionalFields.is_inactive,
phone_numbers: parseContactPhoneNumbers(additionalFields.phone_numbers),
});
}
function normalizeOptionalNumber(value: unknown, fieldName: string): number | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
const parsed = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(`${fieldName} must be a number`);
}
if (parsed < 0) {
throw new Error(`${fieldName} must be a non-negative number`);
}
return parsed;
}
function normalizeOptionalString(value: unknown): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
const trimmed = String(value).trim();
return trimmed === '' ? undefined : trimmed;
}
export function buildProjectTaskCreatePayload(input: {
taskName: string;
statusMappingId: string;
additionalFields?: IDataObject;
}): IDataObject {
const additional = input.additionalFields ?? {};
return compactObject({
task_name: input.taskName,
project_status_mapping_id: input.statusMappingId,
description: normalizeOptionalString(additional.description),
assigned_to: normalizeOptionalUuid(additional.assigned_to, 'assigned_to'),
estimated_hours: normalizeOptionalNumber(additional.estimated_hours, 'estimated_hours'),
due_date: normalizeOptionalString(additional.due_date),
priority_id: normalizeOptionalUuid(additional.priority_id, 'priority_id'),
task_type_key: normalizeOptionalString(additional.task_type_key),
wbs_code: normalizeOptionalString(additional.wbs_code),
tags: parseTags(additional.tags),
});
}
export function buildProjectTaskUpdatePayload(additionalFields: IDataObject = {}): IDataObject {
return compactObject({
task_name: normalizeOptionalString(additionalFields.task_name),
description: normalizeOptionalString(additionalFields.description),
assigned_to: normalizeOptionalUuid(additionalFields.assigned_to, 'assigned_to'),
estimated_hours: normalizeOptionalNumber(additionalFields.estimated_hours, 'estimated_hours'),
due_date: normalizeOptionalString(additionalFields.due_date),
priority_id: normalizeOptionalUuid(additionalFields.priority_id, 'priority_id'),
task_type_key: normalizeOptionalString(additionalFields.task_type_key),
project_status_mapping_id: normalizeOptionalUuid(
additionalFields.project_status_mapping_id,
'project_status_mapping_id',
),
wbs_code: normalizeOptionalString(additionalFields.wbs_code),
tags: parseTags(additionalFields.tags),
});
}
export function buildProjectTaskListQuery(input: {
page: number;
limit: number;
}): IDataObject {
return compactObject({
page: input.page,
limit: input.limit,
});
}
export function buildContactListQuery(input: {
page: number;
limit: number;
filters?: IDataObject;
}): IDataObject {
const filters = input.filters ?? {};
const clientId = normalizeOptionalUuid(filters.client_id, 'client_id');
return compactObject({
page: input.page,
limit: input.limit,
client_id: clientId,
search_term: filters.search_term,
is_inactive: filters.is_inactive,
});
}
export function buildTicketListQuery(input: {
page: number;
limit: number;
sort?: string;
order?: string;
filters?: IDataObject;
}): IDataObject {
return compactObject({
page: input.page,
limit: input.limit,
sort: input.sort,
order: input.order,
...(input.filters ?? {}),
});
}
export function buildTicketSearchQuery(input: {
query: string;
limit?: number;
includeClosed?: boolean;
fields?: string[];
statusIds?: string[];
priorityIds?: string[];
clientIds?: string[];
assignedToIds?: string[];
}): IDataObject {
return compactObject({
query: input.query,
limit: input.limit,
include_closed: input.includeClosed,
fields: input.fields && input.fields.length > 0 ? input.fields.join(',') : undefined,
status_ids:
input.statusIds && input.statusIds.length > 0 ? input.statusIds.join(',') : undefined,
priority_ids:
input.priorityIds && input.priorityIds.length > 0
? input.priorityIds.join(',')
: undefined,
client_ids:
input.clientIds && input.clientIds.length > 0 ? input.clientIds.join(',') : undefined,
assigned_to_ids:
input.assignedToIds && input.assignedToIds.length > 0
? input.assignedToIds.join(',')
: undefined,
});
}
export function buildTicketCommentListQuery(options: IDataObject = {}): IDataObject {
return compactObject({
limit: options.limit,
offset: options.offset,
order: options.order,
});
}
export function buildTicketCommentPayload(
commentText: string,
additionalFields: IDataObject = {},
): IDataObject {
return compactObject({
comment_text: commentText,
is_internal: additionalFields.is_internal,
});
}
export function normalizeSuccessResponse(response: unknown): IDataObject {
if (response === undefined || response === null) {
return {};
}
if (Array.isArray(response)) {
return { data: response };
}
if (isObject(response) && 'data' in response) {
const data = response.data;
const pagination = response.pagination;
if (pagination !== undefined) {
return {
data: Array.isArray(data) ? data : data ?? [],
pagination,
} as IDataObject;
}
if (Array.isArray(data)) {
return { data };
}
if (isObject(data)) {
return data as IDataObject;
}
if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') {
return { data };
}
return { data: JSON.stringify(data ?? null) };
}
if (isObject(response)) {
return response as IDataObject;
}
return { data: response as string | number | boolean };
}
export function normalizeDeleteSuccess(id: string, response?: unknown): IDataObject {
const normalized = normalizeSuccessResponse(response);
return {
success: true,
id,
deleted: true,
...normalized,
};
}
function parseErrorBody(value: unknown): Record<string, unknown> | undefined {
if (isObject(value)) {
return value;
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (isObject(parsed)) {
return parsed;
}
} catch {
return undefined;
}
}
return undefined;
}
export function formatAlgaApiError(error: unknown): AlgaApiError {
const raw = (error ?? {}) as Record<string, unknown>;
const response = isObject(raw.response) ? raw.response : undefined;
const responseBody = parseErrorBody(raw.responseBody) ?? parseErrorBody(raw.body);
const responseData = response ? parseErrorBody(response.data) : undefined;
const merged = responseBody ?? responseData;
const nestedError = merged && isObject(merged.error) ? merged.error : undefined;
const statusCode =
(typeof raw.httpCode === 'number' ? raw.httpCode : undefined) ??
(typeof raw.statusCode === 'number' ? raw.statusCode : undefined) ??
(response && typeof response.status === 'number' ? response.status : undefined);
const code =
(nestedError && typeof nestedError.code === 'string' ? nestedError.code : undefined) ??
(merged && typeof merged.code === 'string' ? merged.code : undefined) ??
(typeof raw.code === 'string' ? raw.code : undefined) ??
(statusCode ? `HTTP_${statusCode}` : 'UNKNOWN_ERROR');
const message =
(nestedError && typeof nestedError.message === 'string' ? nestedError.message : undefined) ??
(merged && typeof merged.message === 'string' ? merged.message : undefined) ??
(typeof raw.message === 'string' ? raw.message : undefined) ??
'Request failed';
const details =
(nestedError && 'details' in nestedError ? nestedError.details : undefined) ??
(merged && 'details' in merged ? merged.details : undefined);
return {
statusCode,
code,
message,
details,
};
}