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 = { 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( connection: EdgeConnection | null | undefined, ): ProductHuntConnection { 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 } 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 }>({ 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 } | 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 }>({ 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 }>({ 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, } }