Skip to content

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

Package listing

@kody/producthunt

src/setup.ts

133 lines · 5.1 KB · TypeScript
import type { ProductHuntAuthInput } from './types.ts'
import { optionalString, requireString } from './types.ts'

export const PRODUCTHUNT_GRAPHQL_URL = 'https://api.producthunt.com/v2/api/graphql'
export const PRODUCTHUNT_AUTHORIZE_URL = 'https://api.producthunt.com/v2/oauth/authorize'
export const PRODUCTHUNT_TOKEN_URL = 'https://api.producthunt.com/v2/oauth/token'
export const PRODUCTHUNT_APPLICATIONS_URL = 'https://api.producthunt.com/v2/oauth/applications'
export const PRODUCTHUNT_REQUIRED_HOST = 'api.producthunt.com'
export const PRODUCTHUNT_CALLBACK_URL = 'https://kody.codes/connect/oauth'
export const DEFAULT_INTEGRATION_NAME = 'producthunt'
export const DEFAULT_ACCESS_TOKEN_SECRET = 'producthuntAccessToken'
export const DEFAULT_CLIENT_SECRET_SECRET = 'producthuntClientSecret'
export const DEFAULT_OAUTH_SCOPES = ['public', 'private'] as const
export const PRODUCTHUNT_DAY_TIMEZONE = 'America/Los_Angeles'

export const PRODUCTHUNT_SCOPES = {
	public: 'Read published posts, hunters, topics, collections, and comments.',
	private: 'Read viewer.user for the connected hunter.',
	write: 'Goal and follow mutations. Product Hunt must approve the app first.',
} as const

export type ProductHuntScope = keyof typeof PRODUCTHUNT_SCOPES
export type ProductHuntOperation = 'query' | 'mutation'

export function oauthConnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string {
	const name = requireString(provider, 'provider')
	const params = new URLSearchParams({
		provider: name,
		authorizeUrl: PRODUCTHUNT_AUTHORIZE_URL,
		tokenUrl: PRODUCTHUNT_TOKEN_URL,
		apiBaseUrl: PRODUCTHUNT_GRAPHQL_URL,
		flow: 'confidential',
		allowedHosts: PRODUCTHUNT_REQUIRED_HOST,
		dashboardUrl: PRODUCTHUNT_APPLICATIONS_URL,
		scopes: DEFAULT_OAUTH_SCOPES.join(' '),
	})
	return `${PRODUCTHUNT_CALLBACK_URL}?${params.toString()}`
}

export function reconnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string {
	return `${PRODUCTHUNT_CALLBACK_URL}?provider=${encodeURIComponent(requireString(provider, 'provider'))}`
}

export function accessTokenSetupUrl(secretName: string = DEFAULT_ACCESS_TOKEN_SECRET): string {
	const name = requireString(secretName, 'secretName')
	const params = new URLSearchParams({
		name,
		description: 'Product Hunt developer or OAuth access token for GraphQL reads',
		allowedHosts: PRODUCTHUNT_REQUIRED_HOST,
		scope: 'user',
	})
	return `https://kody.codes/account/secrets/new?${params.toString()}`
}

export function clientSecretSetupUrl(secretName: string = DEFAULT_CLIENT_SECRET_SECRET): string {
	const name = requireString(secretName, 'secretName')
	const params = new URLSearchParams({
		name,
		description: 'Product Hunt OAuth client secret for the confidential app',
		allowedHosts: PRODUCTHUNT_REQUIRED_HOST,
		scope: 'user',
	})
	return `https://kody.codes/account/secrets/new?${params.toString()}`
}

export function resolveIntegrationName(input: ProductHuntAuthInput = {}): string {
	const explicit =
		optionalString(input.integrationName, 'integrationName') ??
		optionalString(input.integration, 'integration')
	if (explicit) return explicit

	const account = optionalString(input.account, 'account')
	if (!account || account === 'default' || account === 'producthunt') {
		return DEFAULT_INTEGRATION_NAME
	}
	if (account.startsWith('producthunt-')) return account
	return `producthunt-${account}`
}

export function resolveSecretName(input: ProductHuntAuthInput = {}): string {
	const explicit = optionalString(input.secretName, 'secretName')
	if (explicit) return explicit
	const integrationName = resolveIntegrationName(input)
	if (integrationName === DEFAULT_INTEGRATION_NAME) return DEFAULT_ACCESS_TOKEN_SECRET
	const suffix = integrationName.startsWith('producthunt-')
		? integrationName.slice('producthunt-'.length)
		: integrationName
	return `${DEFAULT_ACCESS_TOKEN_SECRET}-${suffix}`
}

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

export function inferOperation(query: string): ProductHuntOperation {
	return /\bmutation\b/i.test(query) ? 'mutation' : 'query'
}

export function isReadOnlyQuery(query: string): boolean {
	return inferOperation(query) === 'query'
}

export function scopeForOperation(operation: ProductHuntOperation): ProductHuntScope {
	switch (operation) {
		case 'query':
			return 'public'
		case 'mutation':
			return 'write'
		default: {
			const exhaustive: never = operation
			throw new Error(`Unsupported Product Hunt operation: ${String(exhaustive)}`)
		}
	}
}

export function missingCredentialsMessage(options: {
	integrationName: string
	secretName: string
}): string {
	return [
		'Product Hunt credentials are missing.',
		'OAuth (recommended): create a Confidential app at',
		PRODUCTHUNT_APPLICATIONS_URL,
		`with redirect URI ${PRODUCTHUNT_CALLBACK_URL}, then connect:`,
		oauthConnectUrl(options.integrationName),
		'Developer token (public reads): save it (do not paste the value in chat):',
		accessTokenSetupUrl(options.secretName),
		`Required API host: ${PRODUCTHUNT_REQUIRED_HOST}.`,
	].join(' ')
}