From b7ad8ebf479a3982d4a2c892c27d91f52a8ab57b Mon Sep 17 00:00:00 2001
From: MaikoCode <71674307+MaikoCode@users.noreply.github.com>
Date: Wed, 13 May 2026 22:11:55 +0100
Subject: [PATCH 1/4] chore: prepare for Node 24 runtime
---
.github/workflows/ci.yml | 2 +-
README.md | 2 +-
package-lock.json | 2 +-
package.json | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9314adc..e99aa2a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,7 +28,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
- node-version: 20
+ node-version: 24
cache: npm
- name: Install dependencies
diff --git a/README.md b/README.md
index 387e702..20aa11c 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ This repository is no longer just a starter template. It already includes a real
### Prerequisites
-- Node.js 20.9+
+- Node.js 24+
- A Convex account
- A Google OAuth app for sign-in
- A Meta app if you want to test Instagram connection and webhooks
diff --git a/package-lock.json b/package-lock.json
index 5c15466..46ff3d4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -42,7 +42,7 @@
"vitest": "^4.1.3"
},
"engines": {
- "node": ">=20.9.0"
+ "node": ">=24.0.0"
}
},
"node_modules/@alloc/quick-lru": {
diff --git a/package.json b/package.json
index d7ce232..acd074f 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
},
"homepage": "https://github.com/MaikoCode/nudgra-convex#readme",
"engines": {
- "node": ">=20.9.0"
+ "node": ">=24.0.0"
},
"scripts": {
"dev": "npm-run-all --parallel dev:frontend dev:backend",
From b27c9e16672efa27aa638e384acc17cbedbffe1f Mon Sep 17 00:00:00 2001
From: MaikoCode <71674307+MaikoCode@users.noreply.github.com>
Date: Sat, 30 May 2026 02:20:13 +0100
Subject: [PATCH 2/4] Disable unsupported follower automations
---
convex/automations/followerAutomations.ts | 81 +++++++++++++----------
convex/meta/config.ts | 1 -
docs/meta-setup.md | 4 ++
tests/follower-automation.test.ts | 50 ++++++++++----
4 files changed, 87 insertions(+), 49 deletions(-)
diff --git a/convex/automations/followerAutomations.ts b/convex/automations/followerAutomations.ts
index 69ad483..90e148e 100644
--- a/convex/automations/followerAutomations.ts
+++ b/convex/automations/followerAutomations.ts
@@ -23,6 +23,9 @@ const nullableSequenceDefinitionId = v.union(
type LatestFollowerSession = Doc<"followerAutomationSessions"> | null;
+const FOLLOWER_AUTOMATION_UNSUPPORTED_MESSAGE =
+ "Instagram does not expose a public new-follower webhook trigger, so follower welcome automations cannot be set live. Use a comment, story reply, or keyword DM automation instead.";
+
function getSerializedLinkButtons(automation: Doc<"followerAutomations">) {
if (automation.linkButtons && automation.linkButtons.length > 0) {
return automation.linkButtons;
@@ -93,10 +96,10 @@ function serializeFollowerAutomation(
? {
id: automation.sequenceDefinitionId,
name: sequencesById.get(automation.sequenceDefinitionId)!.name,
- isActive:
- sequencesById.get(automation.sequenceDefinitionId)!.isActive,
- stepCount:
- sequencesById.get(automation.sequenceDefinitionId)!.steps.length,
+ isActive: sequencesById.get(automation.sequenceDefinitionId)!
+ .isActive,
+ stepCount: sequencesById.get(automation.sequenceDefinitionId)!.steps
+ .length,
}
: null,
sequenceDefinitionId: automation.sequenceDefinitionId,
@@ -104,6 +107,8 @@ function serializeFollowerAutomation(
guardrailReason: automation.guardrailReason ?? null,
guardrailSessionId: automation.guardrailSessionId ?? null,
guardrailConversationId: automation.guardrailConversationId ?? null,
+ canGoLive: false,
+ unsupportedReason: FOLLOWER_AUTOMATION_UNSUPPORTED_MESSAGE,
validationIssues: getFollowerAutomationValidationIssues({
...automation,
linkButtons,
@@ -159,7 +164,9 @@ async function validateFollowerRelations(
if (args.sequenceDefinitionId !== null) {
const sequence = await ctx.db.get(args.sequenceDefinitionId);
if (sequence === null || sequence.workspaceId !== args.workspaceId) {
- throw new Error("The selected sequence does not belong to this workspace.");
+ throw new Error(
+ "The selected sequence does not belong to this workspace.",
+ );
}
}
}
@@ -198,9 +205,7 @@ function isTerminalFollowerSession(
step: Doc<"followerAutomationSessions">["currentStep"],
) {
return (
- step === "completed" ||
- step === "link_sent" ||
- step === "guardrail_tripped"
+ step === "completed" || step === "link_sent" || step === "guardrail_tripped"
);
}
@@ -308,7 +313,10 @@ export const getFollowerAutomationCreationOptions = query({
]);
return {
- hasConnectedAccount: account !== null && account.status !== "disconnected",
+ hasConnectedAccount:
+ account !== null && account.status !== "disconnected",
+ isRealtimeTriggerSupported: false,
+ unsupportedReason: FOLLOWER_AUTOMATION_UNSUPPORTED_MESSAGE,
tags: tags.map((tag) => ({
id: tag._id,
label: tag.label,
@@ -393,6 +401,10 @@ export const createFollowerAutomation = mutation({
throw new Error("Automation name is required.");
}
+ if (args.goLive) {
+ throw new Error(FOLLOWER_AUTOMATION_UNSUPPORTED_MESSAGE);
+ }
+
await validateFollowerRelations(ctx, {
workspaceId: workspace._id,
tagIds: [...new Set(args.tagIds)],
@@ -401,9 +413,7 @@ export const createFollowerAutomation = mutation({
const normalized = normalizeFollowerAutomationInput(args);
const now = Date.now();
- const status: Doc<"followerAutomations">["status"] = args.goLive
- ? "live"
- : "draft";
+ const status: Doc<"followerAutomations">["status"] = "draft";
const automationId = await ctx.db.insert("followerAutomations", {
workspaceId: workspace._id,
@@ -415,7 +425,8 @@ export const createFollowerAutomation = mutation({
emailCollectionEnabled: args.emailCollectionEnabled,
emailCollectionText: args.emailCollectionText.trim(),
linkDmText: args.linkDmText.trim(),
- linkUrl: normalized.primaryLink?.url ?? normalizeAbsoluteUrl(args.linkUrl),
+ linkUrl:
+ normalized.primaryLink?.url ?? normalizeAbsoluteUrl(args.linkUrl),
linkButtonText:
normalized.primaryLink?.label ||
args.linkButtonText.trim() ||
@@ -489,7 +500,8 @@ export const updateFollowerAutomation = mutation({
emailCollectionEnabled: args.emailCollectionEnabled,
emailCollectionText: args.emailCollectionText.trim(),
linkDmText: args.linkDmText.trim(),
- linkUrl: normalized.primaryLink?.url ?? normalizeAbsoluteUrl(args.linkUrl),
+ linkUrl:
+ normalized.primaryLink?.url ?? normalizeAbsoluteUrl(args.linkUrl),
linkButtonText:
normalized.primaryLink?.label ||
args.linkButtonText.trim() ||
@@ -502,11 +514,22 @@ export const updateFollowerAutomation = mutation({
followUpText: args.followUpText.trim(),
tagIds: [...new Set(args.tagIds)],
sequenceDefinitionId: args.sequenceDefinitionId,
- guardrailTrippedAt: automation.status === "live" ? null : automation.guardrailTrippedAt ?? null,
- guardrailReason: automation.status === "live" ? null : automation.guardrailReason ?? null,
- guardrailSessionId: automation.status === "live" ? null : automation.guardrailSessionId ?? null,
+ guardrailTrippedAt:
+ automation.status === "live"
+ ? null
+ : (automation.guardrailTrippedAt ?? null),
+ guardrailReason:
+ automation.status === "live"
+ ? null
+ : (automation.guardrailReason ?? null),
+ guardrailSessionId:
+ automation.status === "live"
+ ? null
+ : (automation.guardrailSessionId ?? null),
guardrailConversationId:
- automation.status === "live" ? null : automation.guardrailConversationId ?? null,
+ automation.status === "live"
+ ? null
+ : (automation.guardrailConversationId ?? null),
lastModifiedAt: Date.now(),
});
@@ -534,27 +557,15 @@ export const toggleFollowerAutomation = mutation({
}
if (args.status === "live") {
- const issues = getFollowerAutomationValidationIssues({
- ...automation,
- linkButtons: getSerializedLinkButtons(automation),
- });
- if (issues.length > 0) {
- throw new Error(issues[0] ?? "Automation must be fixed before going live.");
- }
+ throw new Error(FOLLOWER_AUTOMATION_UNSUPPORTED_MESSAGE);
}
await ctx.db.patch(automation._id, {
status: args.status,
- guardrailTrippedAt:
- args.status === "live" ? null : (automation.guardrailTrippedAt ?? null),
- guardrailReason:
- args.status === "live" ? null : (automation.guardrailReason ?? null),
- guardrailSessionId:
- args.status === "live" ? null : (automation.guardrailSessionId ?? null),
- guardrailConversationId:
- args.status === "live"
- ? null
- : (automation.guardrailConversationId ?? null),
+ guardrailTrippedAt: automation.guardrailTrippedAt ?? null,
+ guardrailReason: automation.guardrailReason ?? null,
+ guardrailSessionId: automation.guardrailSessionId ?? null,
+ guardrailConversationId: automation.guardrailConversationId ?? null,
lastModifiedAt: Date.now(),
});
diff --git a/convex/meta/config.ts b/convex/meta/config.ts
index f7853a9..981144d 100644
--- a/convex/meta/config.ts
+++ b/convex/meta/config.ts
@@ -10,7 +10,6 @@ export const META_WEBHOOK_SUBSCRIBED_FIELDS = [
"messages",
"messaging_postbacks",
"comments",
- "followers",
];
export function getMetaWebhookSubscribedFields() {
diff --git a/docs/meta-setup.md b/docs/meta-setup.md
index 03aeb29..315dfee 100644
--- a/docs/meta-setup.md
+++ b/docs/meta-setup.md
@@ -60,6 +60,10 @@ Meta's webhook delivery also has two separate requirements:
After account connection, Nudgra also attempts to subscribe the connected Instagram account to the app's webhook delivery through the Graph API.
The account-level subscription requests `messages`, `messaging_postbacks`, and `comments` so comment-triggered private replies can open the normal 24-hour DM window after a tap or reply.
+Meta's public Instagram webhook fields do not include a new-follower trigger.
+Unfollowing and following a test account will not produce a webhook that Nudgra
+can use for a welcome DM.
+
Important: a "Connected" account in Nudgra only confirms OAuth/token storage and
that the app attempted the account-level `subscribed_apps` call. It does not
guarantee that the app-level webhook callback is configured correctly.
diff --git a/tests/follower-automation.test.ts b/tests/follower-automation.test.ts
index f231cc7..d6772a5 100644
--- a/tests/follower-automation.test.ts
+++ b/tests/follower-automation.test.ts
@@ -119,7 +119,7 @@ describe("follower automations", () => {
delete process.env.SITE_URL;
});
- it("creates, lists, toggles, validates, and deletes follower automations", async () => {
+ it("creates, lists, blocks live toggles, validates, and deletes follower automations", async () => {
const t = convexTest({ schema, modules });
const fixture = await seedWorkspace(t);
const tagId = await insertTag(t, fixture, "new follower");
@@ -134,6 +134,8 @@ describe("follower automations", () => {
expect(options.sequences.map((sequence) => sequence.name)).toEqual([
"Follower nurture",
]);
+ expect(options.isRealtimeTriggerSupported).toBe(false);
+ expect(options.unsupportedReason).toContain("new-follower webhook");
await expect(
authT.mutation(
@@ -145,13 +147,25 @@ describe("follower automations", () => {
linkUrl: "",
linkDmText: "",
followUpEnabled: true,
- goLive: true,
+ goLive: false,
}),
accountId: fixture.instagramAccountId,
},
),
).rejects.toThrow("Follow-up requires at least one tracked link button.");
+ await expect(
+ authT.mutation(
+ api.automations.followerAutomations.createFollowerAutomation,
+ {
+ ...buildCreateArgs({
+ goLive: true,
+ }),
+ accountId: fixture.instagramAccountId,
+ },
+ ),
+ ).rejects.toThrow("new-follower webhook");
+
const createResult = await authT.mutation(
api.automations.followerAutomations.createFollowerAutomation,
{
@@ -173,6 +187,8 @@ describe("follower automations", () => {
},
);
expect(automation?.status).toBe("draft");
+ expect(automation?.canGoLive).toBe(false);
+ expect(automation?.unsupportedReason).toContain("new-follower webhook");
expect(automation?.validationIssues).toEqual([]);
expect(automation?.tags.map((tag) => tag.label)).toEqual(["new follower"]);
expect(automation?.sequence?.name).toBe("Follower nurture");
@@ -193,14 +209,16 @@ describe("follower automations", () => {
automationId: createResult.automationId,
},
);
- await authT.mutation(
- api.automations.followerAutomations.toggleFollowerAutomation,
- {
- accountId: fixture.instagramAccountId,
- automationId: createResult.automationId,
- status: "live",
- },
- );
+ await expect(
+ authT.mutation(
+ api.automations.followerAutomations.toggleFollowerAutomation,
+ {
+ accountId: fixture.instagramAccountId,
+ automationId: createResult.automationId,
+ status: "live",
+ },
+ ),
+ ).rejects.toThrow("new-follower webhook");
automation = await authT.query(
api.automations.followerAutomations.getFollowerAutomationById,
@@ -210,7 +228,7 @@ describe("follower automations", () => {
},
);
expect(automation?.name).toBe("Follower welcome edited");
- expect(automation?.status).toBe("live");
+ expect(automation?.status).toBe("draft");
const listed = await authT.query(
api.automations.followerAutomations.listFollowerAutomations,
@@ -258,12 +276,16 @@ describe("follower automations", () => {
tagIds: [tagId],
sequenceDefinitionId: sequenceId,
followUpEnabled: true,
- goLive: true,
+ goLive: false,
}),
accountId: fixture.instagramAccountId,
},
);
+ await t.run(async (ctx) => {
+ await ctx.db.patch(createResult.automationId, { status: "live" });
+ });
+
const firstResult = await t.mutation(
internal.meta.followerWebhooks.processFollowerEvent,
{
@@ -295,7 +317,9 @@ describe("follower automations", () => {
const stored = await t.run(async (ctx) => {
const contacts = await ctx.db.query("contacts").collect();
const conversations = await ctx.db.query("conversations").collect();
- const sessions = await ctx.db.query("followerAutomationSessions").collect();
+ const sessions = await ctx.db
+ .query("followerAutomationSessions")
+ .collect();
const attempts = await ctx.db.query("deliveryAttempts").collect();
const trackedLinks = await ctx.db
.query("followerAutomationTrackedLinks")
From 3968181883445201d524f1e5a6f412fde6c7d63c Mon Sep 17 00:00:00 2001
From: MaikoCode <71674307+MaikoCode@users.noreply.github.com>
Date: Sat, 30 May 2026 02:20:26 +0100
Subject: [PATCH 3/4] Show follower automation unavailable state
---
.../followers/[automationId]/page.tsx | 14 +++-
.../automations/followers/new/page.tsx | 17 +++-
app/dashboard/automations/page.tsx | 79 ++++++++++++-------
.../dashboard/automation-type-modal.tsx | 11 ++-
.../dashboard/follower-automation-form.tsx | 5 +-
5 files changed, 90 insertions(+), 36 deletions(-)
diff --git a/app/dashboard/automations/followers/[automationId]/page.tsx b/app/dashboard/automations/followers/[automationId]/page.tsx
index ca975bf..289b027 100644
--- a/app/dashboard/automations/followers/[automationId]/page.tsx
+++ b/app/dashboard/automations/followers/[automationId]/page.tsx
@@ -78,7 +78,8 @@ export default function FollowerAutomationDetailPage() {
linkButtons,
followUpEnabled,
});
- const canGoLive = validationIssues.length === 0;
+ const canGoLive =
+ validationIssues.length === 0 && automation?.canGoLive === true;
async function handleSave() {
if (
@@ -227,6 +228,11 @@ export default function FollowerAutomationDetailPage() {
})
}
disabled={automation.status !== "live" && !canGoLive}
+ title={
+ automation.status !== "live" && automation.unsupportedReason
+ ? automation.unsupportedReason
+ : undefined
+ }
className="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40"
>
{automation.status === "live" ? (
@@ -259,6 +265,12 @@ export default function FollowerAutomationDetailPage() {
+ {automation.unsupportedReason ? (
+
+ {automation.unsupportedReason}
+
+ ) : null}
+