Skip to content

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

Package listing

@kody/zendesk

src/tickets.ts

411 lines · 12.7 KB · TypeScript
import { parseAuth } from './auth.ts'
import { zendeskRequest } from './client.ts'
import { compactDefined, extractListItems, mapTicket, mapTicketComment } from './models.ts'
import type {
	DryRunResult,
	JsonRecord,
	MutationInput,
	ZendeskAuthInput,
	ZendeskPageInfo,
	ZendeskTicket,
	ZendeskTicketComment,
} from './types.ts'
import {
	clampInt,
	optionalBoolean,
	optionalNumber,
	optionalString,
	requireRecord,
	requireString,
} from './types.ts'

export type ListTicketsInput = ZendeskAuthInput & {
	perPage?: number
	page?: number
	sortBy?: string
	sortOrder?: string
}

export type GetTicketInput = ZendeskAuthInput & {
	id: number | string
}

export type SearchTicketsInput = ZendeskAuthInput & {
	query: string
	perPage?: number
	page?: number
	sortBy?: string
	sortOrder?: string
}

export type CreateTicketInput = ZendeskAuthInput &
	MutationInput & {
		subject: string
		comment: string
		requesterId?: number
		requesterEmail?: string
		requesterName?: string
		assigneeId?: number
		priority?: string
		type?: string
		status?: string
		tags?: Array<string>
	}

export type UpdateTicketInput = ZendeskAuthInput &
	MutationInput & {
		id: number | string
		subject?: string
		status?: string
		priority?: string
		type?: string
		assigneeId?: number
		comment?: string
		publicComment?: boolean
		tags?: Array<string>
	}

export type ListTicketCommentsInput = ZendeskAuthInput & {
	id: number | string
	perPage?: number
	page?: number
}

export type AddTicketCommentInput = ZendeskAuthInput &
	MutationInput & {
		id: number | string
		comment: string
		publicComment?: boolean
	}

export type TicketListResult = {
	items: Array<ZendeskTicket>
	pageInfo: ZendeskPageInfo
}

export type TicketCommentListResult = {
	items: Array<ZendeskTicketComment>
	pageInfo: ZendeskPageInfo
}

function ticketId(value: number | string, label = 'id'): string {
	if (typeof value === 'number' && Number.isInteger(value) && value > 0) return String(value)
	return requireString(value, label)
}

function searchQuery(query: string): string {
	const trimmed = requireString(query, 'query')
	return /\btype:\s*ticket\b/i.test(trimmed) ? trimmed : `type:ticket ${trimmed}`
}

/**
 * List Zendesk tickets.
 * @example
 * import { listTickets } from 'kody:@kody/zendesk/tickets'
 * const { items } = await listTickets({ perPage: 10, subdomain: 'acme' })
 */
export async function listTickets(input: ListTicketsInput = {}): Promise<TicketListResult> {
	const result = await zendeskRequest<unknown>({
		...input,
		operation: 'tickets.read',
		path: '/tickets',
		query: compactDefined({
			per_page: clampInt(input.perPage, 1, 100, 20),
			page: input.page === undefined ? undefined : clampInt(input.page, 1, 10_000, 1, 'page'),
			sort_by: input.sortBy,
			sort_order: input.sortOrder,
		}),
	})
	if ('dryRun' in result) throw new Error('listTickets is read-only.')
	return {
		items: extractListItems(result.data, ['tickets'])
			.map(mapTicket)
			.filter((item): item is ZendeskTicket => Boolean(item)),
		pageInfo: result.pageInfo,
	}
}

/**
 * Get one Zendesk ticket by id.
 * @example
 * import { getTicket } from 'kody:@kody/zendesk/tickets'
 * const ticket = await getTicket({ id, subdomain: 'acme' })
 */
export async function getTicket(input: GetTicketInput): Promise<ZendeskTicket> {
	const id = ticketId(input.id)
	const result = await zendeskRequest<unknown>({
		...input,
		operation: 'tickets.read',
		path: `/tickets/${encodeURIComponent(id)}`,
	})
	if ('dryRun' in result) throw new Error('getTicket is read-only.')
	const mapped = mapTicket(result.data)
	if (!mapped) throw new Error('Zendesk did not return a ticket id.')
	return mapped
}

/**
 * Search Zendesk tickets. GET `/search` is a read.
 * @example
 * import { searchTickets } from 'kody:@kody/zendesk/tickets'
 * const { items } = await searchTickets({ query: 'status:open', subdomain: 'acme' })
 */
export async function searchTickets(input: SearchTicketsInput): Promise<TicketListResult> {
	const result = await zendeskRequest<unknown>({
		...input,
		operation: 'tickets.read',
		path: '/search',
		query: compactDefined({
			query: searchQuery(input.query),
			per_page: clampInt(input.perPage, 1, 100, 20),
			page: input.page === undefined ? undefined : clampInt(input.page, 1, 10_000, 1, 'page'),
			sort_by: input.sortBy,
			sort_order: input.sortOrder,
		}),
	})
	if ('dryRun' in result) throw new Error('searchTickets is read-only.')
	return {
		items: extractListItems(result.data, ['results', 'tickets'])
			.map(mapTicket)
			.filter((item): item is ZendeskTicket => Boolean(item)),
		pageInfo: result.pageInfo,
	}
}

function createTicketBody(input: CreateTicketInput): JsonRecord {
	const requester =
		input.requesterId !== undefined
			? undefined
			: input.requesterEmail || input.requesterName
				? compactDefined({
						email: input.requesterEmail,
						name: input.requesterName,
					})
				: undefined
	return {
		ticket: compactDefined({
			subject: requireString(input.subject, 'subject'),
			comment: { body: requireString(input.comment, 'comment') },
			requester_id: input.requesterId,
			requester,
			assignee_id: input.assigneeId,
			priority: input.priority,
			type: input.type,
			status: input.status,
			tags: input.tags,
		}),
	}
}

/**
 * Create a Zendesk ticket. Requires `confirm: true`, or use `dryRun: true`.
 * @example
 * import { createTicket } from 'kody:@kody/zendesk/tickets'
 * const preview = await createTicket({ subject: 'Reset password', comment: 'User cannot sign in.', dryRun: true, subdomain: 'acme' })
 */
export async function createTicket(
	input: CreateTicketInput,
): Promise<ZendeskTicket | DryRunResult> {
	const result = await zendeskRequest<unknown>({
		...input,
		operation: 'tickets.write',
		method: 'POST',
		path: '/tickets',
		body: createTicketBody(input),
	})
	if ('dryRun' in result) return result
	const mapped = mapTicket(result.data)
	if (!mapped) throw new Error('Zendesk did not return a created ticket id.')
	return mapped
}

function updateTicketBody(input: UpdateTicketInput | AddTicketCommentInput): JsonRecord {
	const comment = 'comment' in input && input.comment
		? compactDefined({
				body: requireString(input.comment, 'comment'),
				public: input.publicComment,
			})
		: undefined
	return {
		ticket: compactDefined({
			subject: 'subject' in input ? input.subject : undefined,
			status: 'status' in input ? input.status : undefined,
			priority: 'priority' in input ? input.priority : undefined,
			type: 'type' in input ? input.type : undefined,
			assignee_id: 'assigneeId' in input ? input.assigneeId : undefined,
			tags: 'tags' in input ? input.tags : undefined,
			comment,
		}),
	}
}

/**
 * Update a Zendesk ticket. Requires `confirm: true`, or use `dryRun: true`.
 * @example
 * import { updateTicket } from 'kody:@kody/zendesk/tickets'
 * const preview = await updateTicket({ id, status: 'solved', dryRun: true, subdomain: 'acme' })
 */
export async function updateTicket(
	input: UpdateTicketInput,
): Promise<ZendeskTicket | DryRunResult> {
	const id = ticketId(input.id)
	const result = await zendeskRequest<unknown>({
		...input,
		operation: 'tickets.write',
		method: 'PUT',
		path: `/tickets/${encodeURIComponent(id)}`,
		body: updateTicketBody(input),
	})
	if ('dryRun' in result) return result
	const mapped = mapTicket(result.data)
	if (!mapped) throw new Error('Zendesk did not return an updated ticket id.')
	return mapped
}

/**
 * List comments on a Zendesk ticket.
 * @example
 * import { listTicketComments } from 'kody:@kody/zendesk/tickets'
 * const { items } = await listTicketComments({ id, subdomain: 'acme' })
 */
export async function listTicketComments(
	input: ListTicketCommentsInput,
): Promise<TicketCommentListResult> {
	const id = ticketId(input.id)
	const result = await zendeskRequest<unknown>({
		...input,
		operation: 'tickets.read',
		path: `/tickets/${encodeURIComponent(id)}/comments`,
		query: compactDefined({
			per_page: clampInt(input.perPage, 1, 100, 20),
			page: input.page === undefined ? undefined : clampInt(input.page, 1, 10_000, 1, 'page'),
		}),
	})
	if ('dryRun' in result) throw new Error('listTicketComments is read-only.')
	return {
		items: extractListItems(result.data, ['comments'])
			.map(mapTicketComment)
			.filter((item): item is ZendeskTicketComment => Boolean(item)),
		pageInfo: result.pageInfo,
	}
}

/**
 * Add a comment to a Zendesk ticket. Requires `confirm: true`, or use `dryRun: true`.
 * @example
 * import { addTicketComment } from 'kody:@kody/zendesk/tickets'
 * const preview = await addTicketComment({ id, comment: 'Looking into this.', dryRun: true, subdomain: 'acme' })
 */
export async function addTicketComment(
	input: AddTicketCommentInput,
): Promise<ZendeskTicket | DryRunResult> {
	const id = ticketId(input.id)
	const result = await zendeskRequest<unknown>({
		...input,
		operation: 'tickets.write',
		method: 'PUT',
		path: `/tickets/${encodeURIComponent(id)}`,
		body: updateTicketBody(input),
	})
	if ('dryRun' in result) return result
	const mapped = mapTicket(result.data)
	if (!mapped) throw new Error('Zendesk did not return the updated ticket id.')
	return mapped
}

export function parseListTickets(params: Record<string, unknown>): ListTicketsInput {
	const input = requireRecord(params, 'list-tickets')
	return {
		...parseAuth(input),
		perPage: input.perPage as number | undefined,
		page: input.page as number | undefined,
		sortBy: optionalString(input.sortBy, 'sortBy'),
		sortOrder: optionalString(input.sortOrder, 'sortOrder'),
	}
}

export function parseGetTicket(params: Record<string, unknown>): GetTicketInput {
	const input = requireRecord(params, 'get-ticket')
	return { ...parseAuth(input), id: input.id as number | string }
}

export function parseSearchTickets(params: Record<string, unknown>): SearchTicketsInput {
	const input = requireRecord(params, 'search-tickets')
	return {
		...parseAuth(input),
		query: requireString(input.query, 'query'),
		perPage: input.perPage as number | undefined,
		page: input.page as number | undefined,
		sortBy: optionalString(input.sortBy, 'sortBy'),
		sortOrder: optionalString(input.sortOrder, 'sortOrder'),
	}
}

export function parseCreateTicket(params: Record<string, unknown>): CreateTicketInput {
	const input = requireRecord(params, 'create-ticket')
	return {
		...parseAuth(input),
		subject: requireString(input.subject, 'subject'),
		comment: requireString(input.comment, 'comment'),
		requesterId: optionalNumber(input.requesterId, 'requesterId'),
		requesterEmail: optionalString(input.requesterEmail, 'requesterEmail'),
		requesterName: optionalString(input.requesterName, 'requesterName'),
		assigneeId: optionalNumber(input.assigneeId, 'assigneeId'),
		priority: optionalString(input.priority, 'priority'),
		type: optionalString(input.type, 'type'),
		status: optionalString(input.status, 'status'),
		tags: Array.isArray(input.tags) ? input.tags.map((tag) => requireString(tag, 'tags[]')) : undefined,
		confirm: optionalBoolean(input.confirm, 'confirm'),
		dryRun: optionalBoolean(input.dryRun, 'dryRun'),
	}
}

export function parseUpdateTicket(params: Record<string, unknown>): UpdateTicketInput {
	const input = requireRecord(params, 'update-ticket')
	return {
		...parseAuth(input),
		id: input.id as number | string,
		subject: optionalString(input.subject, 'subject'),
		status: optionalString(input.status, 'status'),
		priority: optionalString(input.priority, 'priority'),
		type: optionalString(input.type, 'type'),
		assigneeId: optionalNumber(input.assigneeId, 'assigneeId'),
		comment: optionalString(input.comment, 'comment'),
		publicComment: optionalBoolean(input.publicComment, 'publicComment'),
		tags: Array.isArray(input.tags) ? input.tags.map((tag) => requireString(tag, 'tags[]')) : undefined,
		confirm: optionalBoolean(input.confirm, 'confirm'),
		dryRun: optionalBoolean(input.dryRun, 'dryRun'),
	}
}

export function parseListTicketComments(params: Record<string, unknown>): ListTicketCommentsInput {
	const input = requireRecord(params, 'list-ticket-comments')
	return {
		...parseAuth(input),
		id: input.id as number | string,
		perPage: input.perPage as number | undefined,
		page: input.page as number | undefined,
	}
}

export function parseAddTicketComment(params: Record<string, unknown>): AddTicketCommentInput {
	const input = requireRecord(params, 'add-ticket-comment')
	return {
		...parseAuth(input),
		id: input.id as number | string,
		comment: requireString(input.comment, 'comment'),
		publicComment: optionalBoolean(input.publicComment, 'publicComment'),
		confirm: optionalBoolean(input.confirm, 'confirm'),
		dryRun: optionalBoolean(input.dryRun, 'dryRun'),
	}
}

/**
 * Zendesk ticket helpers. Writes require `confirm: true` or `dryRun: true`.
 * @example
 * import { listTickets } from 'kody:@kody/zendesk/tickets'
 * const { items } = await listTickets({ perPage: 10, subdomain: 'acme' })
 */
export default async function ticketsEntrypoint(params: Record<string, unknown> = {}) {
	return listTickets(parseListTickets(params))
}