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} +