Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/mg-beehiiv-api-members/.eslintrc.cjs
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'
}
};
3 changes: 3 additions & 0 deletions packages/mg-beehiiv-api-members/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Migrate beehiiv members API

...
43 changes: 43 additions & 0 deletions packages/mg-beehiiv-api-members/package.json
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"
}
}
9 changes: 9 additions & 0 deletions packages/mg-beehiiv-api-members/src/index.ts
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
};
Comment on lines +1 to +9
Copy link
Contributor

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.ts convention and forces namespace-style consumption everywhere.

♻️ Proposed change
-import {listPublications} from './lib/list-pubs.js';
-import {fetchTasks} from './lib/fetch.js';
-import {mapMembersTasks} from './lib/mapper.js';
-
-export default {
-    listPublications,
-    fetchTasks,
-    mapMembersTasks
-};
+export {listPublications} from './lib/list-pubs.js';
+export {fetchTasks} from './lib/fetch.js';
+export {mapMembersTasks} from './lib/mapper.js';

As per coding guidelines, "Named exports should be used for utilities, and default exports for main functionality (classes)."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/mg-beehiiv-api-members/src/index.ts` around lines 1 - 9, Replace the
default object export with named exports for the utility functions; instead of
exporting a default object containing listPublications, fetchTasks, and
mapMembersTasks, export each symbol by name (listPublications, fetchTasks,
mapMembersTasks) so consumers can import them directly (e.g., import {
listPublications } from ...). Update the package entrypoint to use named exports
for the listed functions and remove the default export.

114 changes: 114 additions & 0 deletions packages/mg-beehiiv-api-members/src/lib/fetch.ts
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}`
}
});
};

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Namespace the cache filename by pubId.

Within a shared tmp cache, page 0 for two different publications both map to beehiiv_api_members_0.json, so one run can replay another publication's cached payload. Include pubId in the key before reading/writing the cache file.

🔧 Minimal fix
-    const filename = `beehiiv_api_members_${cursorIndex}.json`;
+    const filename = `beehiiv_api_members_${encodeURIComponent(pubId)}_${cursorIndex}.json`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const filename = `beehiiv_api_members_${cursorIndex}.json`;
if (fileCache.hasFile(filename, 'tmp')) {
return await fileCache.readTmpJSONFile(filename);
const filename = `beehiiv_api_members_${encodeURIComponent(pubId)}_${cursorIndex}.json`;
if (fileCache.hasFile(filename, 'tmp')) {
return await fileCache.readTmpJSONFile(filename);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/mg-beehiiv-api-members/src/lib/fetch.ts` around lines 37 - 40, The
tmp cache filename is not namespaced by pubId so different publications collide;
update the filename construction in this function to include pubId (e.g., change
the filename variable used with fileCache.hasFile and fileCache.readTmpJSONFile
from `beehiiv_api_members_${cursorIndex}.json` to include pubId, such as
`${pubId}_beehiiv_api_members_${cursorIndex}.json`) so all fileCache operations
(hasFile/readTmpJSONFile and any corresponding writes) use the pubId-prefixed
key.

}

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential division issue if totalSubscriptions is undefined or zero.

If discover() returns undefined (when stats?.active_subscriptions is not set), line 65 will compute Math.ceil(undefined / 100) resulting in NaN. Additionally, if totalSubscriptions is 0, the estimated pages will be 0 which is technically correct but the progress message may be misleading.

🛡️ 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
Verify each finding against the current code and only fix it if needed.

In `@packages/mg-beehiiv-api-members/src/lib/fetch.ts` around lines 63 - 65, The
code in fetchTasks calling discover(options.key, options.id) can return
undefined leading to NaN when computing estimatedPages; update fetchTasks to
coerce totalSubscriptions to a safe number (e.g., const totalSubscriptions =
Number(await discover(...)) || 0) and compute estimatedPages defensively (e.g.,
const estimatedPages = totalSubscriptions > 0 ? Math.ceil(totalSubscriptions /
API_LIMIT) : 0) and ensure any progress/logging paths handle the zero-pages case
appropriately; reference fetchTasks, discover, API_LIMIT and estimatedPages when
making these changes.


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
};
21 changes: 21 additions & 0 deletions packages/mg-beehiiv-api-members/src/lib/list-pubs.ts
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 -20

Repository: 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 -100

Repository: TryGhost/migrate

Length of output: 7411


Add context object to the structured error.

The message alone loses which endpoint failed. Per coding guidelines, @tryghost/errors requires both message and context properties. Add a context object with the request URL/status metadata so CLI failures are diagnosable without leaking the API key.

🔧 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
Verify each finding against the current code and only fix it if needed.

In `@packages/mg-beehiiv-api-members/src/lib/list-pubs.ts` around lines 10 - 11,
The throw currently only sets a message; change it to pass a structured context
to errors.InternalServerError that includes the failing endpoint metadata (e.g.
response.status and response.statusText) and a non-sensitive form of the request
URL (use the response.url but strip query/credentials—e.g. new
URL(response.url).pathname or origin+pathname) so the context helps debugging
without leaking API keys; update the throw that uses errors.InternalServerError
to include both message and a context object containing { status:
response.status, statusText: response.statusText, path: <stripped-url-path> }
referencing the existing response and errors.InternalServerError symbols.

}

const data = await response.json();

return data.data;
};

export {
listPublications
};
106 changes: 106 additions & 0 deletions packages/mg-beehiiv-api-members/src/lib/mapper.ts
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
};
Loading