Skip to content

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

Package listing

@kentcdodds/producthunt

src/domain.ts

437 lines · 10.7 KB · TypeScript
import { PRODUCTHUNT_INTEGRATION, request } from './request.ts'
import type {
	ProductHuntCollectionSummary,
	ProductHuntCommentSummary,
	ProductHuntConnection,
	ProductHuntGetCollectionOptions,
	ProductHuntGetPostOptions,
	ProductHuntGetTopicOptions,
	ProductHuntGetUserOptions,
	ProductHuntIntegrationOptions,
	ProductHuntListCollectionsOptions,
	ProductHuntListCommentsOptions,
	ProductHuntListPostsOptions,
	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
}

function integrationOf(options: ProductHuntIntegrationOptions = {}) {
	return options.integration ?? PRODUCTHUNT_INTEGRATION
}

/** Clamp a Product Hunt page size to the API's 1–20 range. */
export function clampPageSize(first?: number, fallback = 10) {
	const value = first ?? fallback
	if (!Number.isFinite(value)) return fallback
	return Math.min(20, Math.max(1, Math.trunc(value)))
}

/** 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,
	}
}

/**
 * Return the connected hunter from `viewer.user` (`private` scope).
 */
export async function getViewer(options: ProductHuntIntegrationOptions = {}) {
	const result = await request<{ viewer: { user: ProductHuntUserSummary } }>({
		query: `query ProductHuntViewer { viewer { user { ${USER_FIELDS} } } }`,
		integration: integrationOf(options),
	})
	const user = result.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 result = await request<{ user: ProductHuntUserSummary | null }>({
		query: `query ProductHuntUser($id: ID, $username: String) {
			user(id: $id, username: $username) { ${USER_FIELDS} }
		}`,
		variables: { id: id || undefined, username: username || undefined },
		integration: integrationOf(options),
	})
	if (!result.data?.user) throw new Error('No Product Hunt user matched that id or username.')
	return result.data.user
}

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

/**
 * List posts with optional featured, date, topic, and order filters.
 */
export async function listPosts(options: ProductHuntListPostsOptions = {}) {
	const result = await request<{ posts: EdgeConnection<RawPost> }>({
		query: `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} }
			}
		}`,
		variables: {
			featured: options.featured,
			postedAfter: options.postedAfter,
			postedBefore: options.postedBefore,
			topic: options.topic,
			order: options.order ?? 'RANKING',
			first: clampPageSize(options.first),
			after: options.after,
		},
		integration: integrationOf(options),
	})
	const connection = unwrapConnection(result.data?.posts)
	return {
		nodes: connection.nodes.map(mapPost),
		pageInfo: connection.pageInfo,
	}
}

/**
 * List comments on a post identified by id or slug.
 */
export async function listComments(options: ProductHuntListCommentsOptions) {
	const { id, slug } = requireIdOrSlug(
		{ id: options.postId, slug: options.slug },
		'listComments',
	)
	const result = await request<{
		post: { comments: EdgeConnection<ProductHuntCommentSummary> } | null
	}>({
		query: `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} }
				}
			}
		}`,
		variables: {
			id,
			slug,
			order: options.order ?? 'NEWEST',
			first: clampPageSize(options.first),
			after: options.after,
		},
		integration: integrationOf(options),
	})
	if (!result.data?.post) throw new Error('No Product Hunt post matched that id or slug.')
	return unwrapConnection(result.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 result = await request<{ collection: ProductHuntCollectionSummary | null }>({
		query: `query ProductHuntCollection($id: ID, $slug: String) {
			collection(id: $id, slug: $slug) { ${COLLECTION_FIELDS} }
		}`,
		variables: { id, slug },
		integration: integrationOf(options),
	})
	if (!result.data?.collection) {
		throw new Error('No Product Hunt collection matched that id or slug.')
	}
	return result.data.collection
}

/**
 * List collections with optional post, user, featured, and order filters.
 */
export async function listCollections(options: ProductHuntListCollectionsOptions = {}) {
	const result = await request<{ collections: EdgeConnection<ProductHuntCollectionSummary> }>({
		query: `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} }
			}
		}`,
		variables: {
			postId: options.postId,
			userId: options.userId,
			featured: options.featured,
			order: options.order ?? 'FEATURED_AT',
			first: clampPageSize(options.first),
			after: options.after,
		},
		integration: integrationOf(options),
	})
	return unwrapConnection(result.data?.collections)
}

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

/**
 * List topics with optional search query and follower filters.
 */
export async function listTopics(options: ProductHuntListTopicsOptions = {}) {
	const result = await request<{ topics: EdgeConnection<ProductHuntTopicSummary> }>({
		query: `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} }
			}
		}`,
		variables: {
			query: options.query,
			followedByUserid: options.followedByUserid,
			order: options.order ?? 'FOLLOWERS_COUNT',
			first: clampPageSize(options.first),
			after: options.after,
		},
		integration: integrationOf(options),
	})
	return unwrapConnection(result.data?.topics)
}

/**
 * Verify Product Hunt auth with viewer plus one featured post.
 */
export async function runSmokeTest(options: ProductHuntIntegrationOptions = {}) {
	const [viewer, posts] = await Promise.all([
		getViewer(options),
		listPosts({ ...options, featured: true, first: 1, order: 'RANKING' }),
	])
	return {
		ok: true,
		viewer: {
			id: viewer.id,
			name: viewer.name,
			username: viewer.username,
		},
		featuredPost: posts.nodes[0]
			? {
					id: posts.nodes[0].id,
					name: posts.nodes[0].name,
					slug: posts.nodes[0].slug,
				}
			: null,
	}
}