Skip to content

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

Package listing

@kody/producthunt

src/domain.ts

439 lines · 10.3 KB · TypeScript
import { request } from './request.ts'
import { clampPageSize } from './setup.ts'
import { todayBounds } from './today.ts'

export { clampPageSize }
import type {
	ProductHuntAuthInput,
	ProductHuntCollectionSummary,
	ProductHuntCommentSummary,
	ProductHuntConnection,
	ProductHuntGetCollectionOptions,
	ProductHuntGetPostOptions,
	ProductHuntGetTopicOptions,
	ProductHuntGetUserOptions,
	ProductHuntListCollectionsOptions,
	ProductHuntListCommentsOptions,
	ProductHuntListPostsOptions,
	ProductHuntListTodayPostsOptions,
	ProductHuntListTopicsOptions,
	ProductHuntPageInfo,
	ProductHuntPostSummary,
	ProductHuntTopicSummary,
	ProductHuntUserSummary,
} from './types.ts'

const USER_FIELDS = `
	id
	name
	username
	headline
	url
	profileImage
	twitterUsername
	websiteUrl
	isMaker
	followersCount
	followingCount
`

const POST_FIELDS = `
	id
	name
	slug
	tagline
	description
	url
	website
	votesCount
	commentsCount
	reviewsCount
	reviewsRating
	createdAt
	featuredAt
	dailyRank
	weeklyRank
	isVoted
	thumbnail { url }
	user { ${USER_FIELDS} }
	makers { ${USER_FIELDS} }
	topics(first: 8) { edges { node { id name slug } } }
`

const COLLECTION_FIELDS = `
	id
	name
	tagline
	description
	url
	coverImage
	followersCount
	featuredAt
	createdAt
	isFollowing
	user { ${USER_FIELDS} }
`

const TOPIC_FIELDS = `
	id
	name
	slug
	description
	url
	followersCount
	postsCount
	isFollowing
`

const COMMENT_FIELDS = `
	id
	body
	url
	createdAt
	votesCount
	isVoted
	parentId
	user { ${USER_FIELDS} }
`

const PAGE_INFO_FIELDS = `
	endCursor
	hasNextPage
	hasPreviousPage
	startCursor
`

type EdgeConnection<T> = {
	edges?: Array<{ node?: T | null } | null> | null
	pageInfo?: ProductHuntPageInfo | null
}

/** Unwrap a Relay connection into `{ nodes, pageInfo }`. */
export function unwrapConnection<T>(
	connection: EdgeConnection<T> | null | undefined,
): ProductHuntConnection<T> {
	return {
		nodes: (connection?.edges ?? []).flatMap((edge) => (edge?.node ? [edge.node] : [])),
		pageInfo: connection?.pageInfo ?? {
			endCursor: null,
			hasNextPage: false,
			hasPreviousPage: false,
			startCursor: null,
		},
	}
}

function requireIdOrSlug(options: { id?: string; slug?: string }, label: string) {
	const id = options.id?.trim()
	const slug = options.slug?.trim()
	if (!id && !slug) throw new Error(`${label} requires id or slug.`)
	return { id: id || undefined, slug: slug || undefined }
}

type RawPost = ProductHuntPostSummary & {
	thumbnail?: { url?: string | null } | null
	topics?: EdgeConnection<ProductHuntTopicSummary>
}

function mapPost(post: RawPost): ProductHuntPostSummary {
	return {
		id: post.id,
		name: post.name,
		slug: post.slug,
		tagline: post.tagline,
		description: post.description,
		url: post.url,
		website: post.website,
		votesCount: post.votesCount,
		commentsCount: post.commentsCount,
		reviewsCount: post.reviewsCount,
		reviewsRating: post.reviewsRating,
		createdAt: post.createdAt,
		featuredAt: post.featuredAt,
		dailyRank: post.dailyRank,
		weeklyRank: post.weeklyRank,
		isVoted: post.isVoted,
		thumbnailUrl: post.thumbnail?.url ?? null,
		user: post.user,
		makers: post.makers ?? [],
		topics: unwrapConnection(post.topics).nodes,
	}
}

async function graphqlData<T>(
	query: string,
	variables: Record<string, unknown>,
	auth: ProductHuntAuthInput,
): Promise<T> {
	const result = await request<T>({
		query,
		variables,
		...auth,
	})
	if ('dryRun' in result) {
		throw new Error('Internal Product Hunt helper unexpectedly received a dry-run result.')
	}
	return result.data as T
}

/**
 * Return the connected hunter from `viewer.user` (`private` scope).
 */
export async function getViewer(options: ProductHuntAuthInput = {}) {
	const data = await graphqlData<{ viewer: { user: ProductHuntUserSummary } }>(
		`query ProductHuntViewer { viewer { user { ${USER_FIELDS} } } }`,
		{},
		options,
	)
	const user = data?.viewer?.user
	if (!user) {
		throw new Error(
			'viewer.user was empty. Reconnect the producthunt integration with public and private scopes.',
		)
	}
	return user
}

/**
 * Look up a hunter by id or username.
 */
export async function getUser(options: ProductHuntGetUserOptions) {
	const id = options.id?.trim()
	const username = options.username?.trim()
	if (!id && !username) throw new Error('getUser requires id or username.')

	const data = await graphqlData<{ user: ProductHuntUserSummary | null }>(
		`query ProductHuntUser($id: ID, $username: String) {
			user(id: $id, username: $username) { ${USER_FIELDS} }
		}`,
		{ id: id || undefined, username: username || undefined },
		options,
	)
	if (!data?.user) throw new Error('No Product Hunt user matched that id or username.')
	return data.user
}

/**
 * Look up a post by id or slug.
 */
export async function getPost(options: ProductHuntGetPostOptions) {
	const { id, slug } = requireIdOrSlug(options, 'getPost')
	const data = await graphqlData<{ post: RawPost | null }>(
		`query ProductHuntPost($id: ID, $slug: String) {
			post(id: $id, slug: $slug) { ${POST_FIELDS} }
		}`,
		{ id, slug },
		options,
	)
	if (!data?.post) throw new Error('No Product Hunt post matched that id or slug.')
	return mapPost(data.post)
}

/**
 * List posts with optional featured, date, topic, and order filters.
 */
export async function listPosts(options: ProductHuntListPostsOptions = {}) {
	const data = await graphqlData<{ posts: EdgeConnection<RawPost> }>(
		`query ProductHuntPosts(
			$featured: Boolean
			$postedAfter: DateTime
			$postedBefore: DateTime
			$topic: String
			$order: PostsOrder
			$first: Int
			$after: String
		) {
			posts(
				featured: $featured
				postedAfter: $postedAfter
				postedBefore: $postedBefore
				topic: $topic
				order: $order
				first: $first
				after: $after
			) {
				edges { node { ${POST_FIELDS} } }
				pageInfo { ${PAGE_INFO_FIELDS} }
			}
		}`,
		{
			featured: options.featured,
			postedAfter: options.postedAfter,
			postedBefore: options.postedBefore,
			topic: options.topic,
			order: options.order ?? 'RANKING',
			first: clampPageSize(options.first),
			after: options.after,
		},
		options,
	)
	const connection = unwrapConnection(data?.posts)
	return {
		nodes: connection.nodes.map(mapPost),
		pageInfo: connection.pageInfo,
	}
}

/**
 * List featured hunts for the current Product Hunt day (Pacific Time by default).
 */
export async function listTodayPosts(options: ProductHuntListTodayPostsOptions = {}) {
	const bounds = todayBounds(options.timeZone)
	const page = await listPosts({
		...options,
		featured: options.featured ?? true,
		postedAfter: bounds.postedAfter,
		postedBefore: bounds.postedBefore,
		order: options.order ?? 'RANKING',
	})
	return {
		...page,
		day: bounds.day,
		timeZone: bounds.timeZone,
		postedAfter: bounds.postedAfter,
		postedBefore: bounds.postedBefore,
	}
}

/**
 * List comments on a post identified by id or slug.
 * The public Product Hunt API has no comment-create mutation.
 */
export async function listComments(options: ProductHuntListCommentsOptions) {
	const { id, slug } = requireIdOrSlug({ id: options.postId, slug: options.slug }, 'listComments')
	const data = await graphqlData<{
		post: { comments: EdgeConnection<ProductHuntCommentSummary> } | null
	}>(
		`query ProductHuntComments(
			$id: ID
			$slug: String
			$order: CommentsOrder
			$first: Int
			$after: String
		) {
			post(id: $id, slug: $slug) {
				comments(order: $order, first: $first, after: $after) {
					edges { node { ${COMMENT_FIELDS} } }
					pageInfo { ${PAGE_INFO_FIELDS} }
				}
			}
		}`,
		{
			id,
			slug,
			order: options.order ?? 'NEWEST',
			first: clampPageSize(options.first),
			after: options.after,
		},
		options,
	)
	if (!data?.post) throw new Error('No Product Hunt post matched that id or slug.')
	return unwrapConnection(data.post.comments)
}

/**
 * Look up a published collection by id or slug.
 */
export async function getCollection(options: ProductHuntGetCollectionOptions) {
	const { id, slug } = requireIdOrSlug(options, 'getCollection')
	const data = await graphqlData<{ collection: ProductHuntCollectionSummary | null }>(
		`query ProductHuntCollection($id: ID, $slug: String) {
			collection(id: $id, slug: $slug) { ${COLLECTION_FIELDS} }
		}`,
		{ id, slug },
		options,
	)
	if (!data?.collection) {
		throw new Error('No Product Hunt collection matched that id or slug.')
	}
	return data.collection
}

/**
 * List collections with optional post, user, featured, and order filters.
 */
export async function listCollections(options: ProductHuntListCollectionsOptions = {}) {
	const data = await graphqlData<{ collections: EdgeConnection<ProductHuntCollectionSummary> }>(
		`query ProductHuntCollections(
			$postId: ID
			$userId: ID
			$featured: Boolean
			$order: CollectionsOrder
			$first: Int
			$after: String
		) {
			collections(
				postId: $postId
				userId: $userId
				featured: $featured
				order: $order
				first: $first
				after: $after
			) {
				edges { node { ${COLLECTION_FIELDS} } }
				pageInfo { ${PAGE_INFO_FIELDS} }
			}
		}`,
		{
			postId: options.postId,
			userId: options.userId,
			featured: options.featured,
			order: options.order ?? 'FEATURED_AT',
			first: clampPageSize(options.first),
			after: options.after,
		},
		options,
	)
	return unwrapConnection(data?.collections)
}

/**
 * Look up a topic by id or slug.
 */
export async function getTopic(options: ProductHuntGetTopicOptions) {
	const { id, slug } = requireIdOrSlug(options, 'getTopic')
	const data = await graphqlData<{ topic: ProductHuntTopicSummary | null }>(
		`query ProductHuntTopic($id: ID, $slug: String) {
			topic(id: $id, slug: $slug) { ${TOPIC_FIELDS} }
		}`,
		{ id, slug },
		options,
	)
	if (!data?.topic) throw new Error('No Product Hunt topic matched that id or slug.')
	return data.topic
}

/**
 * List topics with optional search query and follower filters.
 */
export async function listTopics(options: ProductHuntListTopicsOptions = {}) {
	const data = await graphqlData<{ topics: EdgeConnection<ProductHuntTopicSummary> }>(
		`query ProductHuntTopics(
			$query: String
			$followedByUserid: ID
			$order: TopicsOrder
			$first: Int
			$after: String
		) {
			topics(
				query: $query
				followedByUserid: $followedByUserid
				order: $order
				first: $first
				after: $after
			) {
				edges { node { ${TOPIC_FIELDS} } }
				pageInfo { ${PAGE_INFO_FIELDS} }
			}
		}`,
		{
			query: options.query,
			followedByUserid: options.followedByUserid,
			order: options.order ?? 'FOLLOWERS_COUNT',
			first: clampPageSize(options.first),
			after: options.after,
		},
		options,
	)
	return unwrapConnection(data?.topics)
}