Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/hubspot

src/objects.ts

310 lines · 9.4 KB · TypeScript
import { hubspotRequest } from './client.ts'
import {
	mapCrmRecord,
	objectPath,
	parseFilterGroups,
	resolveObjectSpec,
	asRecordArray,
} from './models.ts'
import type {
	DryRunResult,
	HubSpotAuthInput,
	HubSpotCrmRecord,
	HubSpotFilterGroup,
	HubSpotPageInfo,
	JsonRecord,
} from './types.ts'
import {
	clampInt,
	compactRecord,
	optionalBoolean,
	optionalString,
	optionalStringArray,
	requireRecord,
	requireString,
} from './types.ts'

export type ListObjectsInput = HubSpotAuthInput & {
	objectType: string
	limit?: number
	after?: string
	properties?: Array<string>
	archived?: boolean
	associations?: Array<string>
}

export type GetObjectInput = HubSpotAuthInput & {
	objectType: string
	id: string
	idProperty?: string
	properties?: Array<string>
	archived?: boolean
	associations?: Array<string>
}

export type SearchObjectsInput = HubSpotAuthInput & {
	objectType: string
	query?: string
	filterGroups?: Array<HubSpotFilterGroup>
	sorts?: Array<string>
	properties?: Array<string>
	limit?: number
	after?: string
}

export type CreateObjectInput = HubSpotAuthInput & {
	objectType: string
	properties: JsonRecord
	confirm?: boolean
	dryRun?: boolean
}

export type UpdateObjectInput = HubSpotAuthInput & {
	objectType: string
	id: string
	properties: JsonRecord
	idProperty?: string
	confirm?: boolean
	dryRun?: boolean
}

export type ListObjectsResult = {
	objectType: string
	items: Array<HubSpotCrmRecord>
	pageInfo: HubSpotPageInfo
}

function requireProperties(value: unknown, label: string): JsonRecord {
	const record = requireRecord(value, label)
	const output: JsonRecord = {}
	for (const [key, item] of Object.entries(record)) {
		if (item === undefined) continue
		if (item === null || typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') {
			output[key] = item
			continue
		}
		throw new Error(`${label}.${key} must be a string, number, boolean, or null.`)
	}
	if (Object.keys(output).length === 0) {
		throw new Error(`${label} must include at least one property.`)
	}
	return output
}

function mutationGuard(
	action: string,
	input: { confirm?: boolean; dryRun?: boolean },
): void {
	if (input.dryRun) return
	if (input.confirm !== true) {
		throw new Error(
			`${action} requires confirm: true after explicit user approval, or dryRun: true.`,
		)
	}
}

/**
 * List HubSpot CRM records for any object type.
 * Standard types: `contacts`, `companies`, `deals`, `tickets`.
 * Custom types use the object type id or fully qualified name.
 * @example
 * import { listObjects } from 'kody:@kody/hubspot/objects'
 * const { items } = await listObjects({ objectType: 'contacts', limit: 10 })
 */
export async function listObjects(input: ListObjectsInput): Promise<ListObjectsResult> {
	const spec = resolveObjectSpec(input.objectType)
	const limit = clampInt(input.limit, 1, 100, 10)
	const properties = optionalStringArray(input.properties, 'properties') ?? spec.defaultProperties
	const result = await hubspotRequest<unknown>({
		...input,
		operation: spec.readOperation,
		method: 'GET',
		path: objectPath(spec.objectType),
		query: compactRecord({
			limit,
			after: optionalString(input.after, 'after'),
			properties: properties.join(','),
			archived: optionalBoolean(input.archived, 'archived'),
			associations: optionalStringArray(input.associations, 'associations')?.join(','),
		}),
	})
	return {
		objectType: spec.objectType,
		items: asRecordArray(result.data).map((item) => mapCrmRecord(item, spec.objectType)),
		pageInfo: result.pageInfo,
	}
}

/**
 * Get one HubSpot CRM record. Pass `idProperty: 'email'` to look up a contact by email.
 * @example
 * import { getObject } from 'kody:@kody/hubspot/objects'
 * const contact = await getObject({ objectType: 'contacts', id: '123' })
 */
export async function getObject(input: GetObjectInput): Promise<HubSpotCrmRecord> {
	const spec = resolveObjectSpec(input.objectType)
	const id = requireString(input.id, 'id')
	const properties = optionalStringArray(input.properties, 'properties') ?? spec.defaultProperties
	const result = await hubspotRequest<unknown>({
		...input,
		operation: spec.readOperation,
		method: 'GET',
		path: objectPath(spec.objectType, id),
		query: compactRecord({
			idProperty: optionalString(input.idProperty, 'idProperty'),
			properties: properties.join(','),
			archived: optionalBoolean(input.archived, 'archived'),
			associations: optionalStringArray(input.associations, 'associations')?.join(','),
		}),
	})
	return mapCrmRecord(result.data, spec.objectType)
}

/**
 * Search HubSpot CRM records. Reads only.
 * @example
 * import { searchObjects } from 'kody:@kody/hubspot/objects'
 * const { items } = await searchObjects({
 *   objectType: 'contacts',
 *   query: 'acme',
 *   limit: 10,
 * })
 */
export async function searchObjects(input: SearchObjectsInput): Promise<ListObjectsResult> {
	const spec = resolveObjectSpec(input.objectType)
	const limit = clampInt(input.limit, 1, 100, 10)
	const properties = optionalStringArray(input.properties, 'properties') ?? spec.defaultProperties
	const body = compactRecord({
		query: optionalString(input.query, 'query'),
		filterGroups: parseFilterGroups(input.filterGroups),
		sorts: optionalStringArray(input.sorts, 'sorts'),
		properties,
		limit,
		after: optionalString(input.after, 'after'),
	})
	const result = await hubspotRequest<unknown>({
		...input,
		operation: spec.readOperation,
		method: 'POST',
		path: `${objectPath(spec.objectType)}/search`,
		body,
	})
	return {
		objectType: spec.objectType,
		items: asRecordArray(result.data).map((item) => mapCrmRecord(item, spec.objectType)),
		pageInfo: result.pageInfo,
	}
}

/**
 * Create a standard HubSpot CRM record. Requires `confirm: true`, or use `dryRun: true`.
 * Generic custom-object writes stay on `./request`.
 * @example
 * import { createObject } from 'kody:@kody/hubspot/objects'
 * const preview = await createObject({
 *   objectType: 'contacts',
 *   properties: { email: 'pat@example.com' },
 *   dryRun: true,
 * })
 */
export async function createObject(
	input: CreateObjectInput,
): Promise<HubSpotCrmRecord | DryRunResult<{ method: string; path: string; body: JsonRecord }>> {
	const spec = resolveObjectSpec(input.objectType)
	if (spec.kind !== 'standard') {
		throw new Error(
			'createObject only writes the standard contacts, companies, deals, and tickets types. Use ./request with dryRun or confirm for other object types.',
		)
	}
	const properties = requireProperties(input.properties, 'properties')
	const path = objectPath(spec.objectType)
	const body = { properties }
	if (input.dryRun) {
		return { dryRun: true, wouldCall: { method: 'POST', path, body } }
	}
	mutationGuard(`Creating a HubSpot ${spec.objectType} record`, input)
	const result = await hubspotRequest<unknown>({
		...input,
		operation: spec.writeOperation,
		method: 'POST',
		path,
		body,
	})
	return mapCrmRecord(result.data, spec.objectType)
}

/**
 * Update a standard HubSpot CRM record. Requires `confirm: true`, or use `dryRun: true`.
 * @example
 * import { updateObject } from 'kody:@kody/hubspot/objects'
 * const preview = await updateObject({
 *   objectType: 'deals',
 *   id: '123',
 *   properties: { dealstage: 'closedwon' },
 *   dryRun: true,
 * })
 */
export async function updateObject(
	input: UpdateObjectInput,
): Promise<HubSpotCrmRecord | DryRunResult<{ method: string; path: string; body: JsonRecord }>> {
	const spec = resolveObjectSpec(input.objectType)
	if (spec.kind !== 'standard') {
		throw new Error(
			'updateObject only writes the standard contacts, companies, deals, and tickets types. Use ./request with dryRun or confirm for other object types.',
		)
	}
	const id = requireString(input.id, 'id')
	const properties = requireProperties(input.properties, 'properties')
	const path = objectPath(spec.objectType, id)
	const body = { properties }
	if (input.dryRun) {
		return { dryRun: true, wouldCall: { method: 'PATCH', path, body } }
	}
	mutationGuard(`Updating a HubSpot ${spec.objectType} record`, input)
	const result = await hubspotRequest<unknown>({
		...input,
		operation: spec.writeOperation,
		method: 'PATCH',
		path,
		query: compactRecord({
			idProperty: optionalString(input.idProperty, 'idProperty'),
		}),
		body,
	})
	return mapCrmRecord(result.data, spec.objectType)
}

export function pickAuth(input: HubSpotAuthInput): HubSpotAuthInput {
	return {
		integrationName: input.integrationName,
		integration: input.integration,
		account: input.account,
		secretName: input.secretName,
		auth: input.auth,
	}
}

/**
 * Generic HubSpot CRM object reads and standard-object writes.
 * @example
 * import objects from 'kody:@kody/hubspot/objects'
 * const listed = await objects({ objectType: 'contacts', limit: 5 })
 */
export default async function objectsEntrypoint(
	params: Partial<ListObjectsInput> & Record<string, unknown> = {},
) {
	const input = requireRecord(params, 'objects')
	return listObjects({
		objectType: requireString(input.objectType, 'objectType'),
		limit: input.limit as number | undefined,
		after: optionalString(input.after, 'after'),
		properties: optionalStringArray(input.properties, 'properties'),
		archived: optionalBoolean(input.archived, 'archived'),
		associations: optionalStringArray(input.associations, 'associations'),
		integrationName: optionalString(input.integrationName, 'integrationName'),
		integration: optionalString(input.integration, 'integration'),
		account: optionalString(input.account, 'account'),
		secretName: optionalString(input.secretName, 'secretName'),
		auth: input.auth === 'oauth' || input.auth === 'privateApp' ? input.auth : undefined,
	})
}