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)
}