Skip to content

Commit e67d2dc

Browse files
committed
Use beehiiv API for content & members
1 parent 5fc4fe1 commit e67d2dc

39 files changed

Lines changed: 4520 additions & 90 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module.exports = {
2+
parser: '@typescript-eslint/parser',
3+
plugins: ['ghost'],
4+
extends: [
5+
'plugin:ghost/node'
6+
],
7+
rules: {
8+
'no-unused-vars': 'off', // doesn't work with typescript
9+
'no-undef': 'off', // doesn't work with typescript
10+
'ghost/ghost-custom/no-native-errors': 'off',
11+
'ghost/ghost-custom/no-native-error': 'off',
12+
'ghost/ghost-custom/ghost-error-usage': 'off',
13+
// todo: clean this up
14+
'ghost/filenames/match-regex': 'off'
15+
}
16+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Migrate beehiiv members API
2+
3+
...
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "@tryghost/mg-beehiiv-api-members",
3+
"version": "0.1.0",
4+
"repository": "https://github.com/TryGhost/migrate/tree/main/packages/mg-beehiiv-api-members",
5+
"author": "Ghost Foundation",
6+
"license": "MIT",
7+
"type": "module",
8+
"main": "build/index.js",
9+
"types": "build/types.d.ts",
10+
"exports": {
11+
".": {
12+
"development": "./src/index.ts",
13+
"default": "./build/index.js"
14+
}
15+
},
16+
"scripts": {
17+
"dev": "echo \"Implement me!\"",
18+
"build:watch": "tsc --watch --preserveWatchOutput --sourceMap",
19+
"build": "rm -rf build && rm -rf tsconfig.tsbuildinfo && tsc --build --sourceMap",
20+
"prepare": "yarn build",
21+
"lint": "eslint src/ --ext .ts --cache",
22+
"posttest": "yarn lint",
23+
"test": "rm -rf build && yarn build --force && c8 --src src --all --check-coverage --100 --reporter text --reporter cobertura node --test build/test/*.test.js"
24+
},
25+
"files": [
26+
"build"
27+
],
28+
"publishConfig": {
29+
"access": "public"
30+
},
31+
"devDependencies": {
32+
"@typescript-eslint/eslint-plugin": "8.0.0",
33+
"@typescript-eslint/parser": "8.0.0",
34+
"c8": "10.1.3",
35+
"eslint": "8.57.0",
36+
"typescript": "5.9.3"
37+
},
38+
"dependencies": {
39+
"@tryghost/errors": "1.3.8",
40+
"@tryghost/mg-fs-utils": "0.12.14",
41+
"@tryghost/string": "0.2.21"
42+
}
43+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {listPublications} from './lib/list-pubs.js';
2+
import {fetchTasks} from './lib/fetch.js';
3+
import {mapMembersTasks} from './lib/mapper.js';
4+
5+
export default {
6+
listPublications,
7+
fetchTasks,
8+
mapMembersTasks
9+
};
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
const API_LIMIT = 100;
2+
3+
const authedClient = async (apiKey: string, theUrl: URL) => {
4+
return fetch(theUrl, {
5+
method: 'GET',
6+
headers: {
7+
Authorization: `Bearer ${apiKey}`
8+
}
9+
});
10+
};
11+
12+
const discover = async (key: string, pubId: string) => {
13+
const url = new URL(`https://api.beehiiv.com/v2/publications/${pubId}`);
14+
url.searchParams.append('limit', '1');
15+
url.searchParams.append('expand[]', 'stats');
16+
17+
const response = await authedClient(key, url);
18+
19+
if (!response.ok) {
20+
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
21+
}
22+
23+
const data: BeehiivPublicationResponse = await response.json();
24+
25+
return data.data.stats?.active_subscriptions;
26+
};
27+
28+
const cachedFetch = async ({fileCache, key, pubId, cursor, cursorIndex}: {
29+
fileCache: any;
30+
key: string;
31+
pubId: string;
32+
cursor: string | null;
33+
cursorIndex: number;
34+
}) => {
35+
const filename = `beehiiv_api_members_${cursorIndex}.json`;
36+
37+
if (fileCache.hasFile(filename, 'tmp')) {
38+
return await fileCache.readTmpJSONFile(filename);
39+
}
40+
41+
const url = new URL(`https://api.beehiiv.com/v2/publications/${pubId}/subscriptions`);
42+
url.searchParams.append('limit', API_LIMIT.toString());
43+
url.searchParams.append('status', 'active');
44+
url.searchParams.append('expand[]', 'custom_fields');
45+
46+
if (cursor) {
47+
url.searchParams.append('cursor', cursor);
48+
}
49+
50+
const response = await authedClient(key, url);
51+
52+
if (!response.ok) {
53+
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
54+
}
55+
56+
const data: BeehiivSubscriptionsResponse = await response.json();
57+
58+
await fileCache.writeTmpFile(data, filename);
59+
60+
return data;
61+
};
62+
63+
export const fetchTasks = async (options: any, ctx: any) => {
64+
const totalSubscriptions = await discover(options.key, options.id);
65+
const estimatedPages = Math.ceil(totalSubscriptions / API_LIMIT);
66+
67+
const tasks = [
68+
{
69+
title: `Fetching subscriptions (estimated ${estimatedPages} pages)`,
70+
task: async (_: any, task: any) => {
71+
let cursor: string | null = null;
72+
let hasMore = true;
73+
let cursorIndex = 0;
74+
75+
ctx.result.subscriptions = [];
76+
77+
while (hasMore) {
78+
try {
79+
const response: BeehiivSubscriptionsResponse = await cachedFetch({
80+
fileCache: ctx.fileCache,
81+
key: options.key,
82+
pubId: options.id,
83+
cursor,
84+
cursorIndex
85+
});
86+
87+
ctx.result.subscriptions = ctx.result.subscriptions.concat(response.data);
88+
hasMore = response.has_more;
89+
cursor = response.next_cursor;
90+
cursorIndex += 1;
91+
92+
task.output = `Fetched ${ctx.result.subscriptions.length} of ${totalSubscriptions} subscriptions`;
93+
} catch (error) {
94+
const errorMessage = error instanceof Error ? error.message : String(error);
95+
task.output = errorMessage;
96+
throw error;
97+
}
98+
}
99+
100+
task.output = `Fetched ${ctx.result.subscriptions.length} subscriptions`;
101+
}
102+
}
103+
];
104+
105+
return tasks;
106+
};
107+
108+
export {
109+
authedClient,
110+
discover,
111+
cachedFetch
112+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {authedClient} from './fetch.js';
2+
3+
const listPublications = async (apiKey: string) => {
4+
const url = new URL(`https://api.beehiiv.com/v2/publications`);
5+
url.searchParams.append('expand[]', 'stats');
6+
7+
const response = await authedClient(apiKey, url);
8+
9+
if (!response.ok) {
10+
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
11+
}
12+
13+
const data = await response.json();
14+
15+
return data.data;
16+
};
17+
18+
export {
19+
listPublications
20+
};
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import {slugify} from '@tryghost/string';
2+
3+
const extractName = (customFields: Array<{name: string; value: string}>): string | null => {
4+
const firstNameField = customFields.find(f => f.name.toLowerCase() === 'first_name' || f.name.toLowerCase() === 'firstname');
5+
const lastNameField = customFields.find(f => f.name.toLowerCase() === 'last_name' || f.name.toLowerCase() === 'lastname');
6+
7+
const firstName = firstNameField?.value?.trim() || '';
8+
const lastName = lastNameField?.value?.trim() || '';
9+
10+
const combinedName = [firstName, lastName].filter(name => name.length > 0).join(' ');
11+
12+
return combinedName.length > 0 ? combinedName : null;
13+
};
14+
15+
const mapSubscription = (subscription: BeehiivSubscription): GhostMemberObject => {
16+
const labels: string[] = [];
17+
18+
// Add status label
19+
labels.push(`beehiiv-status-${subscription.status}`);
20+
21+
// Add tier label
22+
labels.push(`beehiiv-tier-${subscription.subscription_tier}`);
23+
24+
// Add premium tier names as labels
25+
if (subscription.subscription_premium_tier_names && subscription.subscription_premium_tier_names.length > 0) {
26+
subscription.subscription_premium_tier_names.forEach((tierName: string) => {
27+
const slugifiedTier = slugify(tierName);
28+
labels.push(`beehiiv-premium-${slugifiedTier}`);
29+
});
30+
}
31+
32+
// Add tags as labels
33+
if (subscription.tags && subscription.tags.length > 0) {
34+
subscription.tags.forEach((tag: string) => {
35+
const slugifiedTag = slugify(tag);
36+
labels.push(`beehiiv-tag-${slugifiedTag}`);
37+
});
38+
}
39+
40+
// Determine if this is a complimentary plan
41+
// A member is on a complimentary plan if they have premium access but no Stripe customer ID
42+
const isPremium = subscription.subscription_tier === 'premium';
43+
const hasStripeId = Boolean(subscription.stripe_customer_id);
44+
const complimentaryPlan = isPremium && !hasStripeId;
45+
46+
return {
47+
email: subscription.email,
48+
name: extractName(subscription.custom_fields || []),
49+
note: null,
50+
subscribed_to_emails: subscription.status === 'active',
51+
stripe_customer_id: subscription.stripe_customer_id || '',
52+
complimentary_plan: complimentaryPlan,
53+
labels,
54+
created_at: new Date(subscription.created * 1000)
55+
};
56+
};
57+
58+
const mapSubscriptions = (subscriptions: BeehiivSubscription[]): MappedMembers => {
59+
const result: MappedMembers = {
60+
free: [],
61+
paid: []
62+
};
63+
64+
subscriptions.forEach((subscription) => {
65+
const member = mapSubscription(subscription);
66+
67+
if (member.stripe_customer_id) {
68+
result.paid.push(member);
69+
} else {
70+
result.free.push(member);
71+
}
72+
});
73+
74+
return result;
75+
};
76+
77+
export const mapMembersTasks = (_options: any, ctx: any) => {
78+
const tasks = [
79+
{
80+
title: 'Mapping subscriptions to Ghost member format',
81+
task: async (_: any, task: any) => {
82+
try {
83+
const subscriptions: BeehiivSubscription[] = ctx.result.subscriptions || [];
84+
ctx.result.members = mapSubscriptions(subscriptions);
85+
86+
const freeCount = ctx.result.members.free.length;
87+
const paidCount = ctx.result.members.paid.length;
88+
89+
task.output = `Mapped ${freeCount} free and ${paidCount} paid members`;
90+
} catch (error) {
91+
const errorMessage = error instanceof Error ? error.message : String(error);
92+
task.output = errorMessage;
93+
throw error;
94+
}
95+
}
96+
}
97+
];
98+
99+
return tasks;
100+
};
101+
102+
export {
103+
extractName,
104+
mapSubscription,
105+
mapSubscriptions
106+
};

0 commit comments

Comments
 (0)