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