-
-
Notifications
You must be signed in to change notification settings - Fork 27
beehiiv api tooling #1613
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
beehiiv api tooling #1613
Changes from all commits
e67d2dc
655c1ca
e1d03b3
3fd9988
7eb6cb5
078caff
087b117
14b9dd3
2badf01
1d3d1e5
38a7769
d6b1891
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| module.exports = { | ||
| parser: '@typescript-eslint/parser', | ||
| plugins: ['ghost'], | ||
| extends: [ | ||
| 'plugin:ghost/node' | ||
| ], | ||
| rules: { | ||
| 'no-unused-vars': 'off', // doesn't work with typescript | ||
| 'no-undef': 'off', // doesn't work with typescript | ||
| 'ghost/filenames/match-regex': 'off' | ||
| } | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # Migrate beehiiv members API | ||
|
|
||
| ... |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| { | ||
| "name": "@tryghost/mg-beehiiv-api-members", | ||
| "version": "0.1.0", | ||
| "repository": "https://github.com/TryGhost/migrate/tree/main/packages/mg-beehiiv-api-members", | ||
| "author": "Ghost Foundation", | ||
| "license": "MIT", | ||
| "type": "module", | ||
| "main": "build/index.js", | ||
| "types": "build/types.d.ts", | ||
| "exports": { | ||
| ".": { | ||
| "development": "./src/index.ts", | ||
| "default": "./build/index.js" | ||
| } | ||
| }, | ||
| "scripts": { | ||
| "dev": "echo \"Implement me!\"", | ||
| "build:watch": "tsc --watch --preserveWatchOutput --sourceMap", | ||
| "build": "rm -rf build && rm -rf tsconfig.tsbuildinfo && tsc --build --sourceMap", | ||
| "prepare": "yarn build", | ||
| "lint": "eslint src/ --ext .ts --cache", | ||
| "posttest": "yarn lint", | ||
| "test": "rm -rf build && yarn build --force && c8 --src src --all --check-coverage --100 --reporter text --reporter cobertura node --test build/test/*.test.js" | ||
| }, | ||
| "files": [ | ||
| "build" | ||
| ], | ||
| "publishConfig": { | ||
| "access": "public" | ||
| }, | ||
| "devDependencies": { | ||
| "@typescript-eslint/eslint-plugin": "8.0.0", | ||
| "@typescript-eslint/parser": "8.0.0", | ||
| "c8": "10.1.3", | ||
| "eslint": "8.57.0", | ||
| "typescript": "5.9.3" | ||
| }, | ||
| "dependencies": { | ||
| "@tryghost/errors": "1.3.8", | ||
| "@tryghost/mg-fs-utils": "0.12.14", | ||
| "@tryghost/string": "0.2.21" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import {listPublications} from './lib/list-pubs.js'; | ||
| import {fetchTasks} from './lib/fetch.js'; | ||
| import {mapMembersTasks} from './lib/mapper.js'; | ||
|
|
||
| export default { | ||
| listPublications, | ||
| fetchTasks, | ||
| mapMembersTasks | ||
| }; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,114 @@ | ||||||||||||||||||
| import errors from '@tryghost/errors'; | ||||||||||||||||||
|
|
||||||||||||||||||
| const API_LIMIT = 100; | ||||||||||||||||||
|
|
||||||||||||||||||
| const authedClient = async (apiKey: string, theUrl: URL) => { | ||||||||||||||||||
| return fetch(theUrl, { | ||||||||||||||||||
| method: 'GET', | ||||||||||||||||||
| headers: { | ||||||||||||||||||
| Authorization: `Bearer ${apiKey}` | ||||||||||||||||||
| } | ||||||||||||||||||
| }); | ||||||||||||||||||
| }; | ||||||||||||||||||
PaulAdamDavis marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
|
|
||||||||||||||||||
| const discover = async (key: string, pubId: string) => { | ||||||||||||||||||
| const url = new URL(`https://api.beehiiv.com/v2/publications/${pubId}`); | ||||||||||||||||||
| url.searchParams.append('limit', '1'); | ||||||||||||||||||
| url.searchParams.append('expand[]', 'stats'); | ||||||||||||||||||
|
|
||||||||||||||||||
| const response = await authedClient(key, url); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!response.ok) { | ||||||||||||||||||
| throw new errors.InternalServerError({message: `Request failed: ${response.status} ${response.statusText}`}); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const data: BeehiivPublicationResponse = await response.json(); | ||||||||||||||||||
|
|
||||||||||||||||||
| return data.data.stats?.active_subscriptions; | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| const cachedFetch = async ({fileCache, key, pubId, cursor, cursorIndex}: { | ||||||||||||||||||
| fileCache: any; | ||||||||||||||||||
| key: string; | ||||||||||||||||||
| pubId: string; | ||||||||||||||||||
| cursor: string | null; | ||||||||||||||||||
| cursorIndex: number; | ||||||||||||||||||
| }) => { | ||||||||||||||||||
| const filename = `beehiiv_api_members_${cursorIndex}.json`; | ||||||||||||||||||
|
|
||||||||||||||||||
| if (fileCache.hasFile(filename, 'tmp')) { | ||||||||||||||||||
| return await fileCache.readTmpJSONFile(filename); | ||||||||||||||||||
|
Comment on lines
+37
to
+40
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Namespace the cache filename by Within a shared tmp cache, page 🔧 Minimal fix- const filename = `beehiiv_api_members_${cursorIndex}.json`;
+ const filename = `beehiiv_api_members_${encodeURIComponent(pubId)}_${cursorIndex}.json`;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const url = new URL(`https://api.beehiiv.com/v2/publications/${pubId}/subscriptions`); | ||||||||||||||||||
| url.searchParams.append('limit', API_LIMIT.toString()); | ||||||||||||||||||
| url.searchParams.append('status', 'active'); | ||||||||||||||||||
| url.searchParams.append('expand[]', 'custom_fields'); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (cursor) { | ||||||||||||||||||
| url.searchParams.append('cursor', cursor); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const response = await authedClient(key, url); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!response.ok) { | ||||||||||||||||||
| throw new errors.InternalServerError({message: `Request failed: ${response.status} ${response.statusText}`}); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const data: BeehiivSubscriptionsResponse = await response.json(); | ||||||||||||||||||
|
|
||||||||||||||||||
| await fileCache.writeTmpFile(data, filename); | ||||||||||||||||||
|
|
||||||||||||||||||
| return data; | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| export const fetchTasks = async (options: any, ctx: any) => { | ||||||||||||||||||
| const totalSubscriptions = await discover(options.key, options.id); | ||||||||||||||||||
| const estimatedPages = Math.ceil(totalSubscriptions / API_LIMIT); | ||||||||||||||||||
|
Comment on lines
+65
to
+67
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential division issue if If 🛡️ Proposed defensive handling export const fetchTasks = async (options: any, ctx: any) => {
const totalSubscriptions = await discover(options.key, options.id);
- const estimatedPages = Math.ceil(totalSubscriptions / API_LIMIT);
+ const estimatedPages = totalSubscriptions ? Math.ceil(totalSubscriptions / API_LIMIT) : 0;🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| const tasks = [ | ||||||||||||||||||
| { | ||||||||||||||||||
| title: `Fetching subscriptions (estimated ${estimatedPages} pages)`, | ||||||||||||||||||
| task: async (_: any, task: any) => { | ||||||||||||||||||
| let cursor: string | null = null; | ||||||||||||||||||
| let hasMore = true; | ||||||||||||||||||
| let cursorIndex = 0; | ||||||||||||||||||
|
|
||||||||||||||||||
| ctx.result.subscriptions = []; | ||||||||||||||||||
|
|
||||||||||||||||||
| while (hasMore) { | ||||||||||||||||||
| try { | ||||||||||||||||||
| const response: BeehiivSubscriptionsResponse = await cachedFetch({ | ||||||||||||||||||
| fileCache: ctx.fileCache, | ||||||||||||||||||
| key: options.key, | ||||||||||||||||||
| pubId: options.id, | ||||||||||||||||||
| cursor, | ||||||||||||||||||
| cursorIndex | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| ctx.result.subscriptions = ctx.result.subscriptions.concat(response.data); | ||||||||||||||||||
| hasMore = response.has_more; | ||||||||||||||||||
| cursor = response.next_cursor; | ||||||||||||||||||
| cursorIndex += 1; | ||||||||||||||||||
|
|
||||||||||||||||||
| task.output = `Fetched ${ctx.result.subscriptions.length} of ${totalSubscriptions} subscriptions`; | ||||||||||||||||||
| } catch (error) { | ||||||||||||||||||
| const errorMessage = error instanceof Error ? error.message : String(error); | ||||||||||||||||||
| task.output = errorMessage; | ||||||||||||||||||
| throw error; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| task.output = `Fetched ${ctx.result.subscriptions.length} subscriptions`; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| ]; | ||||||||||||||||||
|
|
||||||||||||||||||
| return tasks; | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| export { | ||||||||||||||||||
| authedClient, | ||||||||||||||||||
| discover, | ||||||||||||||||||
| cachedFetch | ||||||||||||||||||
| }; | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import errors from '@tryghost/errors'; | ||
| import {authedClient} from './fetch.js'; | ||
|
|
||
| const listPublications = async (apiKey: string) => { | ||
| const url = new URL(`https://api.beehiiv.com/v2/publications`); | ||
| url.searchParams.append('expand[]', 'stats'); | ||
|
|
||
| const response = await authedClient(apiKey, url); | ||
|
|
||
| if (!response.ok) { | ||
| throw new errors.InternalServerError({message: `Request failed: ${response.status} ${response.statusText}`}); | ||
|
Comment on lines
+10
to
+11
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, verify the file exists and check the code around lines 10-11
cat -n packages/mg-beehiiv-api-members/src/lib/list-pubs.ts | head -20Repository: TryGhost/migrate Length of output: 753 🏁 Script executed: # Search for other InternalServerError usages to understand the pattern in this codebase
rg -A 3 "InternalServerError" --type ts packages/mg-beehiiv-api-members/Repository: TryGhost/migrate Length of output: 1222 🏁 Script executed: # Also check if there are other similar error patterns with context in the codebase
rg "errors\.(InternalServerError|BadRequest|ValidationError)" -A 5 --type ts | head -100Repository: TryGhost/migrate Length of output: 7411 Add context object to the structured error. The message alone loses which endpoint failed. Per coding guidelines, 🔧 Minimal fix if (!response.ok) {
- throw new errors.InternalServerError({message: `Request failed: ${response.status} ${response.statusText}`});
+ throw new errors.InternalServerError({
+ message: `Request failed: ${response.status} ${response.statusText}`,
+ context: {
+ endpoint: url.toString(),
+ status: response.status,
+ statusText: response.statusText
+ }
+ });
}🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| const data = await response.json(); | ||
|
|
||
| return data.data; | ||
| }; | ||
|
|
||
| export { | ||
| listPublications | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import {slugify} from '@tryghost/string'; | ||
|
|
||
| const extractName = (customFields: Array<{name: string; value: string}>): string | null => { | ||
| const firstNameField = customFields.find(f => f.name.toLowerCase() === 'first_name' || f.name.toLowerCase() === 'firstname'); | ||
| const lastNameField = customFields.find(f => f.name.toLowerCase() === 'last_name' || f.name.toLowerCase() === 'lastname'); | ||
|
|
||
| const firstName = firstNameField?.value?.trim() || ''; | ||
| const lastName = lastNameField?.value?.trim() || ''; | ||
|
|
||
| const combinedName = [firstName, lastName].filter(name => name.length > 0).join(' '); | ||
|
|
||
| return combinedName.length > 0 ? combinedName : null; | ||
| }; | ||
|
|
||
| const mapSubscription = (subscription: BeehiivSubscription): GhostMemberObject => { | ||
| const labels: string[] = []; | ||
|
|
||
| // Add status label | ||
| labels.push(`beehiiv-status-${subscription.status}`); | ||
|
|
||
| // Add tier label | ||
| labels.push(`beehiiv-tier-${subscription.subscription_tier}`); | ||
|
|
||
| // Add premium tier names as labels | ||
| if (subscription.subscription_premium_tier_names && subscription.subscription_premium_tier_names.length > 0) { | ||
| subscription.subscription_premium_tier_names.forEach((tierName: string) => { | ||
| const slugifiedTier = slugify(tierName); | ||
| labels.push(`beehiiv-premium-${slugifiedTier}`); | ||
| }); | ||
| } | ||
|
|
||
| // Add tags as labels | ||
| if (subscription.tags && subscription.tags.length > 0) { | ||
| subscription.tags.forEach((tag: string) => { | ||
| const slugifiedTag = slugify(tag); | ||
| labels.push(`beehiiv-tag-${slugifiedTag}`); | ||
| }); | ||
| } | ||
|
|
||
| // Determine if this is a complimentary plan | ||
| // A member is on a complimentary plan if they have premium access but no Stripe customer ID | ||
| const isPremium = subscription.subscription_tier === 'premium'; | ||
| const hasStripeId = Boolean(subscription.stripe_customer_id); | ||
| const complimentaryPlan = isPremium && !hasStripeId; | ||
|
|
||
| return { | ||
| email: subscription.email, | ||
| name: extractName(subscription.custom_fields || []), | ||
| note: null, | ||
| subscribed_to_emails: subscription.status === 'active', | ||
| stripe_customer_id: subscription.stripe_customer_id || '', | ||
| complimentary_plan: complimentaryPlan, | ||
| labels, | ||
| created_at: new Date(subscription.created * 1000) | ||
| }; | ||
| }; | ||
|
|
||
| const mapSubscriptions = (subscriptions: BeehiivSubscription[]): MappedMembers => { | ||
| const result: MappedMembers = { | ||
| free: [], | ||
| paid: [] | ||
| }; | ||
|
|
||
| subscriptions.forEach((subscription) => { | ||
| const member = mapSubscription(subscription); | ||
|
|
||
| if (member.stripe_customer_id) { | ||
| result.paid.push(member); | ||
| } else { | ||
| result.free.push(member); | ||
| } | ||
| }); | ||
|
|
||
| return result; | ||
| }; | ||
|
|
||
| export const mapMembersTasks = (_options: any, ctx: any) => { | ||
| const tasks = [ | ||
| { | ||
| title: 'Mapping subscriptions to Ghost member format', | ||
| task: async (_: any, task: any) => { | ||
| try { | ||
| const subscriptions: BeehiivSubscription[] = ctx.result.subscriptions || []; | ||
| ctx.result.members = mapSubscriptions(subscriptions); | ||
|
|
||
| const freeCount = ctx.result.members.free.length; | ||
| const paidCount = ctx.result.members.paid.length; | ||
|
|
||
| task.output = `Mapped ${freeCount} free and ${paidCount} paid members`; | ||
| } catch (error) { | ||
| const errorMessage = error instanceof Error ? error.message : String(error); | ||
| task.output = errorMessage; | ||
| throw error; | ||
| } | ||
| } | ||
| } | ||
| ]; | ||
|
|
||
| return tasks; | ||
| }; | ||
|
|
||
| export { | ||
| extractName, | ||
| mapSubscription, | ||
| mapSubscriptions | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Export these utilities by name from the package entrypoint.
This barrel only exposes free functions, so the default object export is fighting the repo’s
index.tsconvention and forces namespace-style consumption everywhere.♻️ Proposed change
As per coding guidelines, "Named exports should be used for utilities, and default exports for main functionality (classes)."
🤖 Prompt for AI Agents