diff --git a/README.md b/README.md index cf65d8f3b..a431150b8 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Each tool has its own detailed documentation: - [Ghost](https://github.com/TryGhost/migrate/tree/main/packages/mg-ghost-api) - [beehiiv](https://github.com/TryGhost/migrate/tree/main/packages/mg-beehiiv) +- [beehiiv API](https://github.com/TryGhost/migrate/tree/main/packages/mg-beehiiv-api) +- [beehiiv API Members](https://github.com/TryGhost/migrate/tree/main/packages/mg-beehiiv-api-members) - [beehiiv Members](https://github.com/TryGhost/migrate/tree/main/packages/mg-beehiiv-members) - [Blogger](https://github.com/TryGhost/migrate/tree/main/packages/mg-blogger) - [Buttondown](https://github.com/TryGhost/migrate/tree/main/packages/mg-buttondown) diff --git a/packages/mg-beehiiv-api-members/.eslintrc.cjs b/packages/mg-beehiiv-api-members/.eslintrc.cjs new file mode 100644 index 000000000..cd6f552f4 --- /dev/null +++ b/packages/mg-beehiiv-api-members/.eslintrc.cjs @@ -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' + } +}; diff --git a/packages/mg-beehiiv-api-members/README.md b/packages/mg-beehiiv-api-members/README.md new file mode 100644 index 000000000..04af663d6 --- /dev/null +++ b/packages/mg-beehiiv-api-members/README.md @@ -0,0 +1,3 @@ +# Migrate beehiiv members API + +... diff --git a/packages/mg-beehiiv-api-members/package.json b/packages/mg-beehiiv-api-members/package.json new file mode 100644 index 000000000..f6678467e --- /dev/null +++ b/packages/mg-beehiiv-api-members/package.json @@ -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" + } +} diff --git a/packages/mg-beehiiv-api-members/src/index.ts b/packages/mg-beehiiv-api-members/src/index.ts new file mode 100644 index 000000000..19b1e8ed3 --- /dev/null +++ b/packages/mg-beehiiv-api-members/src/index.ts @@ -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 +}; diff --git a/packages/mg-beehiiv-api-members/src/lib/fetch.ts b/packages/mg-beehiiv-api-members/src/lib/fetch.ts new file mode 100644 index 000000000..e07b3faac --- /dev/null +++ b/packages/mg-beehiiv-api-members/src/lib/fetch.ts @@ -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}` + } + }); +}; + +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); + } + + 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); + + 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 +}; diff --git a/packages/mg-beehiiv-api-members/src/lib/list-pubs.ts b/packages/mg-beehiiv-api-members/src/lib/list-pubs.ts new file mode 100644 index 000000000..2197557bd --- /dev/null +++ b/packages/mg-beehiiv-api-members/src/lib/list-pubs.ts @@ -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}`}); + } + + const data = await response.json(); + + return data.data; +}; + +export { + listPublications +}; diff --git a/packages/mg-beehiiv-api-members/src/lib/mapper.ts b/packages/mg-beehiiv-api-members/src/lib/mapper.ts new file mode 100644 index 000000000..d202e0050 --- /dev/null +++ b/packages/mg-beehiiv-api-members/src/lib/mapper.ts @@ -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 +}; diff --git a/packages/mg-beehiiv-api-members/src/test/fetch.test.ts b/packages/mg-beehiiv-api-members/src/test/fetch.test.ts new file mode 100644 index 000000000..3ebc1bbdd --- /dev/null +++ b/packages/mg-beehiiv-api-members/src/test/fetch.test.ts @@ -0,0 +1,357 @@ +import assert from 'node:assert/strict'; +import {describe, it, beforeEach, afterEach, mock} from 'node:test'; +import {listPublications} from '../lib/list-pubs.js'; +import {fetchTasks, authedClient, discover, cachedFetch} from '../lib/fetch.js'; + +describe('beehiiv API Members Fetch', () => { + let fetchMock: any; + + beforeEach(() => { + fetchMock = mock.method(global, 'fetch', () => Promise.resolve()); + }); + + afterEach(() => { + mock.restoreAll(); + }); + + describe('authedClient', () => { + it('makes authenticated GET request', async () => { + fetchMock.mock.mockImplementation(() => Promise.resolve({ok: true, json: () => Promise.resolve({data: []})})); + + const url = new URL('https://api.beehiiv.com/v2/publications'); + await authedClient('test-api-key', url); + + assert.equal(fetchMock.mock.callCount(), 1); + const [calledUrl, options] = fetchMock.mock.calls[0].arguments; + assert.equal(calledUrl.toString(), 'https://api.beehiiv.com/v2/publications'); + assert.equal(options.method, 'GET'); + assert.equal(options.headers.Authorization, 'Bearer test-api-key'); + }); + }); + + describe('listPublications', () => { + it('fetches and returns publications', async () => { + const mockPubs = [{id: 'pub-1', name: 'Test Pub'}]; + fetchMock.mock.mockImplementation(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({data: mockPubs}) + })); + + const result = await listPublications('test-key'); + + assert.deepEqual(result, mockPubs); + }); + + it('throws on API error', async () => { + fetchMock.mock.mockImplementation(() => Promise.resolve({ + ok: false, + status: 401, + statusText: 'Unauthorized' + })); + + await assert.rejects(async () => { + await listPublications('invalid-key'); + }, /Request failed: 401 Unauthorized/); + }); + }); + + describe('discover', () => { + it('returns total subscription count', async () => { + fetchMock.mock.mockImplementation(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({data: {stats: {active_subscriptions: 1500}}}) + })); + + const result = await discover('test-key', 'pub-123'); + + assert.equal(result, 1500); + }); + + it('throws on API error', async () => { + fetchMock.mock.mockImplementation(() => Promise.resolve({ + ok: false, + status: 403, + statusText: 'Forbidden' + })); + + await assert.rejects(async () => { + await discover('test-key', 'pub-123'); + }, /Request failed: 403 Forbidden/); + }); + }); + + describe('cachedFetch', () => { + it('returns cached data when available', async () => { + const cachedData = {data: [{id: 'sub-1', email: 'test@example.com'}], has_more: false}; + const fileCache = { + hasFile: () => true, + readTmpJSONFile: () => Promise.resolve(cachedData) + }; + + const result = await cachedFetch({ + fileCache, + key: 'test-key', + pubId: 'pub-123', + cursor: null, + cursorIndex: 0 + }); + + assert.deepEqual(result, cachedData); + assert.equal(fetchMock.mock.callCount(), 0); + }); + + it('fetches from API when not cached', async () => { + const apiData = {data: [{id: 'sub-1', email: 'test@example.com'}], has_more: false, next_cursor: null}; + fetchMock.mock.mockImplementation(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve(apiData) + })); + + const writeTmpFileMock = mock.fn(() => Promise.resolve()); + const fileCache = { + hasFile: () => false, + writeTmpFile: writeTmpFileMock + }; + + const result = await cachedFetch({ + fileCache, + key: 'test-key', + pubId: 'pub-123', + cursor: null, + cursorIndex: 0 + }); + + assert.deepEqual(result, apiData); + assert.equal(writeTmpFileMock.mock.callCount(), 1); + }); + + it('includes cursor when provided', async () => { + const apiData = {data: [], has_more: false, next_cursor: null}; + fetchMock.mock.mockImplementation(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve(apiData) + })); + + const fileCache = { + hasFile: () => false, + writeTmpFile: mock.fn(() => Promise.resolve()) + }; + + await cachedFetch({ + fileCache, + key: 'test-key', + pubId: 'pub-123', + cursor: 'cursor-abc', + cursorIndex: 1 + }); + + const [calledUrl] = fetchMock.mock.calls[0].arguments; + assert.ok(calledUrl.toString().includes('cursor=cursor-abc')); + }); + + it('throws on API error', async () => { + fetchMock.mock.mockImplementation(() => Promise.resolve({ + ok: false, + status: 500, + statusText: 'Internal Server Error' + })); + + const fileCache = { + hasFile: () => false, + writeTmpFile: mock.fn(() => Promise.resolve()) + }; + + await assert.rejects(async () => { + await cachedFetch({ + fileCache, + key: 'test-key', + pubId: 'pub-123', + cursor: null, + cursorIndex: 0 + }); + }, /Request failed: 500 Internal Server Error/); + }); + }); + + describe('fetchTasks', () => { + it('creates a single task that fetches all subscriptions', async () => { + // Mock the discover call + fetchMock.mock.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({total_results: 150, data: [], has_more: true}) + }), 0); + + const options = {key: 'test-key', id: 'pub-123'}; + const ctx = { + fileCache: { + hasFile: () => false, + writeTmpFile: () => Promise.resolve() + }, + result: {} + }; + + const tasks = await fetchTasks(options, ctx); + + assert.equal(tasks.length, 1); + assert.ok(tasks[0].title.includes('Fetching subscriptions')); + }); + + it('task fetches all pages using cursor pagination', async () => { + // Mock the discover call + fetchMock.mock.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({total_results: 3, data: [], has_more: true}) + }), 0); + + // Mock first page + fetchMock.mock.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + data: [{id: 'sub-1', email: 'a@test.com'}], + has_more: true, + next_cursor: 'cursor-1' + }) + }), 1); + + // Mock second page + fetchMock.mock.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + data: [{id: 'sub-2', email: 'b@test.com'}], + has_more: false, + next_cursor: null + }) + }), 2); + + const options = {key: 'test-key', id: 'pub-123'}; + const ctx: any = { + fileCache: { + hasFile: () => false, + writeTmpFile: () => Promise.resolve() + }, + result: {} + }; + + const tasks = await fetchTasks(options, ctx); + await tasks[0].task({}, {output: ''}); + + assert.equal(ctx.result.subscriptions.length, 2); + assert.equal(ctx.result.subscriptions[0].id, 'sub-1'); + assert.equal(ctx.result.subscriptions[1].id, 'sub-2'); + }); + + it('task uses cached data when available', async () => { + // Mock the discover call + fetchMock.mock.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({total_results: 1, data: [], has_more: false}) + }), 0); + + const cachedData = { + data: [{id: 'cached-sub', email: 'cached@test.com'}], + has_more: false, + next_cursor: null + }; + + const options = {key: 'test-key', id: 'pub-123'}; + const ctx: any = { + fileCache: { + hasFile: () => true, + readTmpJSONFile: () => Promise.resolve(cachedData) + }, + result: {} + }; + + const tasks = await fetchTasks(options, ctx); + await tasks[0].task({}, {output: ''}); + + assert.equal(ctx.result.subscriptions.length, 1); + assert.equal(ctx.result.subscriptions[0].id, 'cached-sub'); + // fetch should only be called once (for discover) + assert.equal(fetchMock.mock.callCount(), 1); + }); + + it('task throws and sets output on fetch error', async () => { + // Mock the discover call + fetchMock.mock.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({total_results: 5, data: [], has_more: true}) + }), 0); + + // Mock a failed fetch + fetchMock.mock.mockImplementationOnce(() => Promise.resolve({ + ok: false, + status: 500, + statusText: 'Internal Server Error' + }), 1); + + const options = {key: 'test-key', id: 'pub-123'}; + const ctx = { + fileCache: { + hasFile: () => false, + writeTmpFile: () => Promise.resolve() + }, + result: {} + }; + + const tasks = await fetchTasks(options, ctx); + const mockTask = {output: ''}; + + await assert.rejects(async () => { + await tasks[0].task({}, mockTask); + }, /Request failed: 500 Internal Server Error/); + + assert.ok(mockTask.output.includes('500')); + }); + + it('task handles non-Error thrown objects', async () => { + // Mock the discover call + fetchMock.mock.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({total_results: 5, data: [], has_more: true}) + }), 0); + + // Mock fetch that throws a non-Error value + fetchMock.mock.mockImplementationOnce(() => { + // eslint-disable-next-line no-throw-literal + throw 'Network error string'; + }, 1); + + const options = {key: 'test-key', id: 'pub-123'}; + const ctx = { + fileCache: { + hasFile: () => false, + writeTmpFile: () => Promise.resolve() + }, + result: {} + }; + + const tasks = await fetchTasks(options, ctx); + const mockTask = {output: ''}; + + await assert.rejects(async () => { + await tasks[0].task({}, mockTask); + }); + + assert.equal(mockTask.output, 'Network error string'); + }); + + it('handles discover error', async () => { + fetchMock.mock.mockImplementation(() => Promise.resolve({ + ok: false, + status: 401, + statusText: 'Unauthorized' + })); + + const options = {key: 'invalid-key', id: 'pub-123'}; + const ctx = { + fileCache: {}, + result: {} + }; + + await assert.rejects(async () => { + await fetchTasks(options, ctx); + }, /Request failed: 401 Unauthorized/); + }); + }); +}); diff --git a/packages/mg-beehiiv-api-members/src/test/index.test.ts b/packages/mg-beehiiv-api-members/src/test/index.test.ts new file mode 100644 index 000000000..e26d11826 --- /dev/null +++ b/packages/mg-beehiiv-api-members/src/test/index.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import beehiivApiMembers from '../index.js'; + +describe('beehiiv API Members Package', () => { + it('exports listPublications', () => { + assert.ok(typeof beehiivApiMembers.listPublications === 'function'); + }); + + it('exports fetchTasks', () => { + assert.ok(typeof beehiivApiMembers.fetchTasks === 'function'); + }); + + it('exports mapMembersTasks', () => { + assert.ok(typeof beehiivApiMembers.mapMembersTasks === 'function'); + }); +}); diff --git a/packages/mg-beehiiv-api-members/src/test/mapper.test.ts b/packages/mg-beehiiv-api-members/src/test/mapper.test.ts new file mode 100644 index 000000000..96b5c2912 --- /dev/null +++ b/packages/mg-beehiiv-api-members/src/test/mapper.test.ts @@ -0,0 +1,374 @@ +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import {extractName, mapSubscription, mapSubscriptions, mapMembersTasks} from '../lib/mapper.js'; + +describe('beehiiv API Members Mapper', () => { + describe('extractName', () => { + it('combines first and last name', () => { + const customFields = [ + {name: 'first_name', value: 'John'}, + {name: 'last_name', value: 'Doe'} + ]; + assert.equal(extractName(customFields), 'John Doe'); + }); + + it('handles only first name', () => { + const customFields = [{name: 'first_name', value: 'Jane'}]; + assert.equal(extractName(customFields), 'Jane'); + }); + + it('handles only last name', () => { + const customFields = [{name: 'last_name', value: 'Smith'}]; + assert.equal(extractName(customFields), 'Smith'); + }); + + it('returns null for empty custom fields', () => { + assert.equal(extractName([]), null); + }); + + it('returns null when name fields are empty strings', () => { + const customFields = [ + {name: 'first_name', value: ''}, + {name: 'last_name', value: ''} + ]; + assert.equal(extractName(customFields), null); + }); + + it('trims whitespace from names', () => { + const customFields = [ + {name: 'first_name', value: ' John '}, + {name: 'last_name', value: ' Doe '} + ]; + assert.equal(extractName(customFields), 'John Doe'); + }); + + it('handles alternate field names (firstname/lastname)', () => { + const customFields = [ + {name: 'firstname', value: 'Jane'}, + {name: 'lastname', value: 'Doe'} + ]; + assert.equal(extractName(customFields), 'Jane Doe'); + }); + + it('handles case-insensitive field names', () => { + const customFields = [ + {name: 'First_Name', value: 'Jane'}, + {name: 'Last_Name', value: 'Doe'} + ]; + assert.equal(extractName(customFields), 'Jane Doe'); + }); + }); + + describe('mapSubscription', () => { + const baseSubscription: BeehiivSubscription = { + id: 'sub-123', + email: 'test@example.com', + status: 'active', + created: 1704067200, // 2024-01-01 00:00:00 UTC + subscription_tier: 'free', + subscription_premium_tier_names: [], + stripe_customer_id: null, + custom_fields: [], + tags: [] + }; + + it('maps basic subscription fields', () => { + const result = mapSubscription(baseSubscription); + + assert.equal(result.email, 'test@example.com'); + assert.equal(result.name, null); + assert.equal(result.note, null); + assert.equal(result.subscribed_to_emails, true); + assert.equal(result.stripe_customer_id, ''); + assert.equal(result.complimentary_plan, false); + assert.deepEqual(result.created_at, new Date(1704067200 * 1000)); + }); + + it('sets subscribed_to_emails true for active status', () => { + const subscription = {...baseSubscription, status: 'active' as const}; + const result = mapSubscription(subscription); + assert.equal(result.subscribed_to_emails, true); + }); + + it('sets subscribed_to_emails false for inactive status', () => { + const subscription = {...baseSubscription, status: 'inactive' as const}; + const result = mapSubscription(subscription); + assert.equal(result.subscribed_to_emails, false); + }); + + it('sets subscribed_to_emails false for validating status', () => { + const subscription = {...baseSubscription, status: 'validating' as const}; + const result = mapSubscription(subscription); + assert.equal(result.subscribed_to_emails, false); + }); + + it('sets subscribed_to_emails false for pending status', () => { + const subscription = {...baseSubscription, status: 'pending' as const}; + const result = mapSubscription(subscription); + assert.equal(result.subscribed_to_emails, false); + }); + + it('maps stripe_customer_id when present', () => { + const subscription = {...baseSubscription, stripe_customer_id: 'cus_abc123'}; + const result = mapSubscription(subscription); + assert.equal(result.stripe_customer_id, 'cus_abc123'); + }); + + it('sets complimentary_plan true for premium without stripe_customer_id', () => { + const subscription = { + ...baseSubscription, + subscription_tier: 'premium' as const, + stripe_customer_id: null + }; + const result = mapSubscription(subscription); + assert.equal(result.complimentary_plan, true); + }); + + it('sets complimentary_plan false for premium with stripe_customer_id', () => { + const subscription = { + ...baseSubscription, + subscription_tier: 'premium' as const, + stripe_customer_id: 'cus_abc123' + }; + const result = mapSubscription(subscription); + assert.equal(result.complimentary_plan, false); + }); + + it('sets complimentary_plan false for free tier', () => { + const subscription = {...baseSubscription, subscription_tier: 'free' as const}; + const result = mapSubscription(subscription); + assert.equal(result.complimentary_plan, false); + }); + + it('extracts name from custom_fields', () => { + const subscription = { + ...baseSubscription, + custom_fields: [ + {name: 'first_name', value: 'John'}, + {name: 'last_name', value: 'Doe'} + ] + }; + const result = mapSubscription(subscription); + assert.equal(result.name, 'John Doe'); + }); + + it('adds status label', () => { + const result = mapSubscription(baseSubscription); + assert.ok(result.labels.includes('beehiiv-status-active')); + }); + + it('adds tier label', () => { + const result = mapSubscription(baseSubscription); + assert.ok(result.labels.includes('beehiiv-tier-free')); + }); + + it('adds premium tier names as labels', () => { + const subscription = { + ...baseSubscription, + subscription_tier: 'premium' as const, + subscription_premium_tier_names: ['Gold Plan', 'VIP Access'] + }; + const result = mapSubscription(subscription); + assert.ok(result.labels.includes('beehiiv-premium-gold-plan')); + assert.ok(result.labels.includes('beehiiv-premium-vip-access')); + }); + + it('adds tags as labels', () => { + const subscription = { + ...baseSubscription, + tags: ['Newsletter', 'Tech Updates'] + }; + const result = mapSubscription(subscription); + assert.ok(result.labels.includes('beehiiv-tag-newsletter')); + assert.ok(result.labels.includes('beehiiv-tag-tech-updates')); + }); + + it('handles empty premium tier names array', () => { + const subscription = { + ...baseSubscription, + subscription_premium_tier_names: [] + }; + const result = mapSubscription(subscription); + assert.ok(!result.labels.some(l => l.startsWith('beehiiv-premium-'))); + }); + + it('handles empty tags array', () => { + const subscription = { + ...baseSubscription, + tags: [] + }; + const result = mapSubscription(subscription); + assert.ok(!result.labels.some(l => l.startsWith('beehiiv-tag-'))); + }); + + it('handles undefined custom_fields', () => { + const subscription = { + ...baseSubscription, + custom_fields: undefined as any + }; + const result = mapSubscription(subscription); + assert.equal(result.name, null); + }); + }); + + describe('mapSubscriptions', () => { + it('splits subscriptions into free and paid based on stripe_customer_id', () => { + const subscriptions: BeehiivSubscription[] = [ + { + id: 'sub-1', + email: 'free@test.com', + status: 'active', + created: 1704067200, + subscription_tier: 'free', + subscription_premium_tier_names: [], + stripe_customer_id: null, + custom_fields: [], + tags: [] + }, + { + id: 'sub-2', + email: 'paid@test.com', + status: 'active', + created: 1704067200, + subscription_tier: 'premium', + subscription_premium_tier_names: [], + stripe_customer_id: 'cus_paid123', + custom_fields: [], + tags: [] + } + ]; + + const result = mapSubscriptions(subscriptions); + + assert.equal(result.free.length, 1); + assert.equal(result.paid.length, 1); + assert.equal(result.free[0].email, 'free@test.com'); + assert.equal(result.paid[0].email, 'paid@test.com'); + }); + + it('returns empty arrays for empty input', () => { + const result = mapSubscriptions([]); + assert.deepEqual(result, {free: [], paid: []}); + }); + + it('categorizes complimentary premium as free', () => { + const subscriptions: BeehiivSubscription[] = [ + { + id: 'sub-1', + email: 'comp@test.com', + status: 'active', + created: 1704067200, + subscription_tier: 'premium', + subscription_premium_tier_names: [], + stripe_customer_id: null, // No stripe ID means complimentary + custom_fields: [], + tags: [] + } + ]; + + const result = mapSubscriptions(subscriptions); + + assert.equal(result.free.length, 1); + assert.equal(result.paid.length, 0); + assert.equal(result.free[0].complimentary_plan, true); + }); + }); + + describe('mapMembersTasks', () => { + it('creates a single mapping task', () => { + const ctx = {result: {subscriptions: []}}; + const tasks = mapMembersTasks({}, ctx); + + assert.equal(tasks.length, 1); + assert.equal(tasks[0].title, 'Mapping subscriptions to Ghost member format'); + }); + + it('task maps subscriptions and stores result', async () => { + const ctx: any = { + result: { + subscriptions: [ + { + id: 'sub-1', + email: 'test@test.com', + status: 'active', + created: 1704067200, + subscription_tier: 'free', + subscription_premium_tier_names: [], + stripe_customer_id: null, + custom_fields: [], + tags: [] + } + ] + } + }; + + const tasks = mapMembersTasks({}, ctx); + const mockTask = {output: ''}; + await tasks[0].task({}, mockTask); + + assert.ok(ctx.result.members); + assert.equal(ctx.result.members.free.length, 1); + assert.equal(ctx.result.members.paid.length, 0); + assert.ok(mockTask.output.includes('1 free')); + assert.ok(mockTask.output.includes('0 paid')); + }); + + it('task handles empty subscriptions', async () => { + const ctx: any = {result: {subscriptions: []}}; + + const tasks = mapMembersTasks({}, ctx); + const mockTask = {output: ''}; + await tasks[0].task({}, mockTask); + + assert.deepEqual(ctx.result.members, {free: [], paid: []}); + }); + + it('task handles undefined subscriptions', async () => { + const ctx: any = {result: {}}; + + const tasks = mapMembersTasks({}, ctx); + const mockTask = {output: ''}; + await tasks[0].task({}, mockTask); + + assert.deepEqual(ctx.result.members, {free: [], paid: []}); + }); + + it('task sets error output on failure with Error instance', async () => { + const ctx = { + result: { + subscriptions: 'not an array' // Invalid data + } + }; + + const tasks = mapMembersTasks({}, ctx); + const mockTask = {output: ''}; + + await assert.rejects(async () => { + await tasks[0].task({}, mockTask); + }); + + assert.ok(mockTask.output.length > 0); + }); + + it('task sets error output on failure with non-Error thrown', async () => { + // Create a context that will trigger an error through a getter that throws a string + const ctx: any = { + result: { + get subscriptions() { + // eslint-disable-next-line no-throw-literal + throw 'Custom string error'; + } + } + }; + + const tasks = mapMembersTasks({}, ctx); + const mockTask = {output: ''}; + + await assert.rejects(async () => { + await tasks[0].task({}, mockTask); + }); + + assert.equal(mockTask.output, 'Custom string error'); + }); + }); +}); diff --git a/packages/mg-beehiiv-api-members/src/types.d.ts b/packages/mg-beehiiv-api-members/src/types.d.ts new file mode 100644 index 000000000..3695466f7 --- /dev/null +++ b/packages/mg-beehiiv-api-members/src/types.d.ts @@ -0,0 +1,46 @@ +declare module '@tryghost/errors'; +declare module '@tryghost/mg-fs-utils'; +declare module '@tryghost/string'; + +type BeehiivSubscription = { + id: string; + email: string; + status: 'active' | 'inactive' | 'validating' | 'pending'; + created: number; + subscription_tier: 'free' | 'premium'; + subscription_premium_tier_names: string[]; + stripe_customer_id: string | null; + custom_fields: Array<{name: string; value: string}>; + tags: string[]; +}; + +type BeehiivPublicationResponse = { + data: { + stats: { + active_subscriptions: number; + } + } +} + +type BeehiivSubscriptionsResponse = { + data: BeehiivSubscription[]; + total_results: number; + has_more: boolean; + next_cursor: string | null; +}; + +type GhostMemberObject = { + email: string; + name: string | null; + note: string | null; + subscribed_to_emails: boolean; + stripe_customer_id: string; + complimentary_plan: boolean; + labels: string[]; + created_at: Date; +}; + +type MappedMembers = { + free: GhostMemberObject[]; + paid: GhostMemberObject[]; +}; diff --git a/packages/mg-beehiiv-api-members/tsconfig.json b/packages/mg-beehiiv-api-members/tsconfig.json new file mode 100644 index 000000000..322068b4d --- /dev/null +++ b/packages/mg-beehiiv-api-members/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + + /* Language and Environment */ + "target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + + /* Modules */ + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", /* Specify the root folder within your source files. */ + + /* Emit */ + "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + "outDir": "build", /* Specify an output folder for all emitted files. */ + + /* Interop Constraints */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + + /* Completeness */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "include": ["src/**/*"] +} diff --git a/packages/mg-beehiiv-api/.eslintrc.cjs b/packages/mg-beehiiv-api/.eslintrc.cjs new file mode 100644 index 000000000..cd6f552f4 --- /dev/null +++ b/packages/mg-beehiiv-api/.eslintrc.cjs @@ -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' + } +}; diff --git a/packages/mg-beehiiv-api/.gitignore b/packages/mg-beehiiv-api/.gitignore new file mode 100644 index 000000000..bd7611769 --- /dev/null +++ b/packages/mg-beehiiv-api/.gitignore @@ -0,0 +1,4 @@ +/build +/tsconfig.tsbuildinfo +/tmp +.env diff --git a/packages/mg-beehiiv-api/LICENSE b/packages/mg-beehiiv-api/LICENSE new file mode 100644 index 000000000..efad547e8 --- /dev/null +++ b/packages/mg-beehiiv-api/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-2026 Ghost Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mg-beehiiv-api/README.md b/packages/mg-beehiiv-api/README.md new file mode 100644 index 000000000..2b7958e24 --- /dev/null +++ b/packages/mg-beehiiv-api/README.md @@ -0,0 +1,161 @@ +# Migrate beehiiv API + +Migrate content from beehiiv to Ghost using the beehiiv API, and generate a `zip` file you can import into a Ghost installation. + + +## Install + +To install the CLI, which is required for the Usage commands below: + +```sh +npm install --global @tryghost/migrate +``` + +To use this package in your own project: + +`npm install @tryghost/mg-beehiiv-api --save` + +or + +`yarn add @tryghost/mg-beehiiv-api` + + +## Usage + +To run a beehiiv API migration, the required command is: + +```sh +migrate beehiiv-api --key 1234abcd +``` + +If no `--id` is provided, a list of available publications will be shown. Use the publication ID to run the full migration: + +```sh +migrate beehiiv-api --key 1234abcd --id pub_abcd1234 +``` + +It's possible to pass more options, in order to achieve a better migration file for Ghost: + +- **`--key`** (required) + - beehiiv API key + - string - default: `null` +- **`--id`** + - beehiiv publication ID + - string - default: `null` +- **`--postsAfter`** + - Only migrate posts published on or after this date + - string - default: `null` + - Format: `YYYY-MM-DD` +- **`--postsBefore`** + - Only migrate posts published on or before this date + - string - default: `null` + - Format: `YYYY-MM-DD` +- **`-s` `--scrape`** + - Configure scraping tasks + - string - default: `all` + - Choices: `all`, `web`, `assets`, `none` +- **`--cache`** + - Persist local cache after migration is complete (Only if `--zip` is `true`) + - bool - default: `true` +- **`--tmpPath`** + - Specify the full path where the temporary files will be stored (Defaults a hidden tmp dir) + - string - default: `null` +- **`--outputPath`** + - Specify the full path where the final zip file will be saved to (Defaults to CWD) + - string - default: `null` +- **`--cacheName`** + - Provide a unique name for the cache directory (defaults to a UUID) + - string - default: `null` +- **`-V` `--verbose`** + - Show verbose output + - bool - default: `false` +- **`--zip`** + - Create a zip file (set to false to skip) + - bool - default: `true` + +A more complex migration command could look like this: + +```sh +migrate beehiiv-api --key 1234abcd --id pub_abcd1234 --postsAfter 2024-01-01 --postsBefore 2024-12-31 --verbose +``` + +This will migrate only posts published in 2024, and show all available output in the console. + + +## HTML Processing + +The `processHTML` function transforms beehiiv email HTML into clean, Ghost-compatible HTML. It applies the following transformations in order: + +1. **Variable replacement** — Removes beehiiv template variables (`{{subscriber_id}}`, `{{rp_refer_url}}`, etc.) +2. **Content extraction** — Extracts content from `#content-blocks` +3. **Horizontal rules** — Converts empty `div[style*="border-top"]` to `
` +4. **Sponsored content** — Converts tables containing "Sponsored Content" to Ghost HTML cards (see below) +5. **Images** — Converts `` to Ghost image cards with captions +6. **Blockquotes** — Converts nested `padding-left` divs to `
` +7. **Buttons** — Converts `