diff --git a/src/app/api/redirect/__tests__/route.test.ts b/src/app/api/redirect/__tests__/route.test.ts
index 5aa0a033..69ab1629 100644
--- a/src/app/api/redirect/__tests__/route.test.ts
+++ b/src/app/api/redirect/__tests__/route.test.ts
@@ -156,7 +156,7 @@ describe("redirect route with UTM parameter preservation", () => {
expect(location).toContain("affiliate_id=12345");
});
- test("does not preserve params for relative paths (should use middleware)", async () => {
+ test("does not preserve params for relative paths (should use proxy)", async () => {
const searchParams = new URLSearchParams();
searchParams.set("utm_source", "test-source");
@@ -164,7 +164,7 @@ describe("redirect route with UTM parameter preservation", () => {
const response = await GET(req);
const location = response.headers.get("location");
- // Relative paths should not have params preserved (middleware handles this)
+ // Relative paths should not have params preserved (proxy handles this)
expect(location).toMatch(/^https:\/\/example\.com\/signup$/);
expect(location).not.toContain("utm_source");
});
diff --git a/src/app/api/redirect/route.ts b/src/app/api/redirect/route.ts
index cc49d539..4b21334f 100644
--- a/src/app/api/redirect/route.ts
+++ b/src/app/api/redirect/route.ts
@@ -100,7 +100,7 @@ export async function GET(req: Request) {
// 2) Preserve query parameters from the redirect request if the destination URL doesn't have them
// This allows incoming UTMs and other tracking parameters to be passed through
try {
- // Only preserve params for absolute URLs (relative paths should use middleware logic)
+ // Only preserve params for absolute URLs (relative paths should use proxy logic)
if (/^https?:/i.test(location)) {
const destUrl = new URL(location);
const requestParams = url.searchParams;
diff --git a/src/components/home/ReactivateCampaignInput.tsx b/src/components/home/ReactivateCampaignInput.tsx
index 2dca6a35..13c84679 100644
--- a/src/components/home/ReactivateCampaignInput.tsx
+++ b/src/components/home/ReactivateCampaignInput.tsx
@@ -611,18 +611,18 @@ export function ReactivateCampaignInput({
{/* Enrich Toggle with Info - on same line when no file, new line when file uploaded */}
Enrich
{/* Mobile: Use Dialog, Desktop: Use Popover */}
<>
{
e.preventDefault();
@@ -630,29 +630,29 @@ export function ReactivateCampaignInput({
setShowEnrichInfo(true);
}}
>
-
+
-
+
-
+
What is Enrichment?
-
+
Automatically enhance your contact data with
verified phone numbers, email addresses, and
additional information to improve your outreach
@@ -660,30 +660,36 @@ export function ReactivateCampaignInput({
-
+
What you get:
-
+
- ✓
+ ✓
Verified contact information (phone, email)
- ✓
+ ✓
Enhanced lead data for better targeting
- ✓
+ ✓
+
+ Social media accounts and social activity summary
+
+
+
+ ✓
Higher conversion rates with accurate contacts
- ✓
+ ✓
Time saved on manual data verification
@@ -700,7 +706,7 @@ export function ReactivateCampaignInput({
checked={skipTrace}
onCheckedChange={setSkipTrace}
disabled={isProcessing}
- className="h-7 w-12 shrink-0 border-2 border-slate-300/50 data-[state=checked]:border-sky-500 data-[state=checked]:bg-sky-500 data-[state=unchecked]:bg-slate-200/80 dark:border-slate-600/50 dark:data-[state=checked]:border-sky-500 dark:data-[state=checked]:bg-sky-500 dark:data-[state=unchecked]:bg-slate-700/80 [&>span]:h-6 [&>span]:w-6 [&>span]:shadow-md"
+ className="h-7 w-12 shrink-0 border-2 border-slate-300/50 transition-all data-[state=checked]:border-sky-500 data-[state=checked]:bg-sky-500 data-[state=unchecked]:bg-slate-200/80 hover:border-sky-400/70 hover:shadow-sm dark:border-slate-600/50 dark:data-[state=checked]:border-sky-500 dark:data-[state=checked]:bg-sky-500 dark:data-[state=unchecked]:bg-slate-700/80 dark:hover:border-sky-400/70 dark:hover:shadow-sm [&>span]:h-6 [&>span]:w-6 [&>span]:shadow-md"
/>
@@ -776,6 +782,10 @@ export function ReactivateCampaignInput({
✓
Enhanced lead data for better targeting
+
+ ✓
+ Social media accounts and social activity summary
+
✓
diff --git a/src/components/linktree/tree/linkResolution.ts b/src/components/linktree/tree/linkResolution.ts
index 86cbb2c3..9d22b3ac 100644
--- a/src/components/linktree/tree/linkResolution.ts
+++ b/src/components/linktree/tree/linkResolution.ts
@@ -36,7 +36,7 @@ export function resolveLink(item: LinkTreeItem): {
isExternal = true;
}
- // Always route through internal slug so middleware can increment Redirects (Calls)
+ // Always route through internal slug so proxy can increment Redirects (Calls)
const slugPath = item.slug ? `/${String(item.slug).replace(/^\//, "")}` : "#";
// If destination is an internal path already, keep it; otherwise force slug path
if (!isRelativePath) {
diff --git a/src/middleware.__tests__.ts b/src/middleware.__tests__.ts
index 32cd9f19..6a413bba 100644
--- a/src/middleware.__tests__.ts
+++ b/src/middleware.__tests__.ts
@@ -1,13 +1,16 @@
/**
- * Tests for middleware redirect functionality with UTM parameters
+ * Tests for proxy redirect functionality with UTM parameters
*/
-import { middleware } from "@/middleware";
+///
+
+import { proxy } from "@/proxy";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
+import { vi } from "vitest";
// Mock fetch globally
-global.fetch = jest.fn();
+global.fetch = vi.fn();
// Mock environment variables
const mockEnv: NodeJS.ProcessEnv = {
@@ -17,7 +20,7 @@ const mockEnv: NodeJS.ProcessEnv = {
};
beforeEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
// Assign individual properties (NODE_ENV is read-only, skip it)
process.env.NOTION_KEY = mockEnv.NOTION_KEY;
process.env.NOTION_REDIRECTS_ID = mockEnv.NOTION_REDIRECTS_ID;
@@ -27,7 +30,7 @@ afterEach(() => {
process.env.ALLOW_INCOMING_UTM = undefined;
});
-describe("middleware redirects with UTM parameters", () => {
+describe("proxy redirects with UTM parameters", () => {
const createRequest = (
pathname: string,
searchParams?: URLSearchParams,
@@ -143,7 +146,7 @@ describe("middleware redirects with UTM parameters", () => {
utm_id: "campaign-456",
};
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () =>
@@ -155,7 +158,7 @@ describe("middleware redirects with UTM parameters", () => {
});
const req = createRequest("/pilot");
- const response = await middleware(req);
+ const response = await proxy(req);
expect(response).toBeInstanceOf(NextResponse);
expect(response?.status).toBe(307);
@@ -177,7 +180,7 @@ describe("middleware redirects with UTM parameters", () => {
utm_campaign_relation: "primary-campaign",
};
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () =>
@@ -189,7 +192,7 @@ describe("middleware redirects with UTM parameters", () => {
});
const req = createRequest("/pilot");
- const response = await middleware(req);
+ const response = await proxy(req);
const location = response?.headers.get("location");
expect(location).toContain("utm_campaign=primary-campaign");
@@ -201,7 +204,7 @@ describe("middleware redirects with UTM parameters", () => {
utm_campaign: "fallback-campaign",
};
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () =>
@@ -213,7 +216,7 @@ describe("middleware redirects with UTM parameters", () => {
});
const req = createRequest("/pilot");
- const response = await middleware(req);
+ const response = await proxy(req);
const location = response?.headers.get("location");
expect(location).toContain("utm_campaign=fallback-campaign");
@@ -225,7 +228,7 @@ describe("middleware redirects with UTM parameters", () => {
utm_campaign: "notion-campaign",
};
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () =>
@@ -240,7 +243,7 @@ describe("middleware redirects with UTM parameters", () => {
});
const req = createRequest("/pilot");
- const response = await middleware(req);
+ const response = await proxy(req);
const location = response?.headers.get("location");
expect(location).toContain("utm_source=notion-source");
@@ -257,7 +260,7 @@ describe("middleware redirects with UTM parameters", () => {
utm_campaign: "notion-campaign",
};
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () =>
@@ -273,7 +276,7 @@ describe("middleware redirects with UTM parameters", () => {
searchParams.set("utm_content", "incoming-content");
const req = createRequest("/pilot", searchParams);
- const response = await middleware(req);
+ const response = await proxy(req);
const location = response?.headers.get("location");
// Incoming UTMs override Notion UTMs when ALLOW_INCOMING_UTM is set
@@ -289,7 +292,7 @@ describe("middleware redirects with UTM parameters", () => {
utm_campaign: "notion-campaign",
};
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () =>
@@ -304,7 +307,7 @@ describe("middleware redirects with UTM parameters", () => {
searchParams.set("utm_source", "incoming-source");
const req = createRequest("/pilot", searchParams);
- const response = await middleware(req);
+ const response = await proxy(req);
const location = response?.headers.get("location");
// Notion UTMs should be used, incoming UTMs ignored
@@ -317,7 +320,7 @@ describe("middleware redirects with UTM parameters", () => {
utm_source: "linktree",
};
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () =>
@@ -331,7 +334,7 @@ describe("middleware redirects with UTM parameters", () => {
const req = createRequest("/pilot");
req.headers.set("referer", "https://example.com/linktree");
- const response = await middleware(req);
+ const response = await proxy(req);
const location = response?.headers.get("location");
expect(location).toContain("RedirectSource=Linktree");
@@ -342,7 +345,7 @@ describe("middleware redirects with UTM parameters", () => {
utm_source: "linkedin",
};
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () =>
@@ -354,7 +357,7 @@ describe("middleware redirects with UTM parameters", () => {
});
const req = createRequest("/pilot");
- const response = await middleware(req);
+ const response = await proxy(req);
const location = response?.headers.get("location");
expect(location).toContain("RedirectSource=Direct");
@@ -366,7 +369,7 @@ describe("middleware redirects with UTM parameters", () => {
utm_campaign: "internal-campaign",
};
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () => createNotionResponse("/signup", utmParams),
@@ -377,7 +380,7 @@ describe("middleware redirects with UTM parameters", () => {
});
const req = createRequest("/pilot");
- const response = await middleware(req);
+ const response = await proxy(req);
const location = response?.headers.get("location");
expect(location).toMatch(/^https:\/\/example\.com\/signup/);
@@ -390,7 +393,7 @@ describe("middleware redirects with UTM parameters", () => {
utm_source: "test",
};
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
@@ -417,11 +420,12 @@ describe("middleware redirects with UTM parameters", () => {
});
const req = createRequest("/pilot");
- await middleware(req);
+ await proxy(req);
// Verify increment call was made
expect(global.fetch).toHaveBeenCalledTimes(2);
- const incrementCall = (global.fetch as jest.Mock).mock.calls[1];
+ const incrementCall = (global.fetch as ReturnType).mock
+ .calls[1];
expect(incrementCall[0]).toBe(
"https://api.notion.com/v1/pages/test-page-id",
);
@@ -441,14 +445,14 @@ describe("middleware redirects with UTM parameters", () => {
for (const path of paths) {
const req = createRequest(path);
- const response = await middleware(req);
+ const response = await proxy(req);
expect(response).toBeInstanceOf(NextResponse);
expect(response?.status).toBe(200);
}
});
});
-describe("middleware redirects with Facebook Pixel tracking", () => {
+describe("proxy redirects with Facebook Pixel tracking", () => {
const createRequest = (
pathname: string,
searchParams?: URLSearchParams,
@@ -532,7 +536,7 @@ describe("middleware redirects with Facebook Pixel tracking", () => {
};
test("redirects to client-side tracking page when Facebook Pixel is enabled", async () => {
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () =>
@@ -549,10 +553,10 @@ describe("middleware redirects with Facebook Pixel tracking", () => {
});
const req = createRequest("/pilot");
- const response = await middleware(req);
+ const response = await proxy(req);
expect(response).toBeInstanceOf(NextResponse);
- expect(response?.status).toBe(307); // Middleware uses 307
+ expect(response?.status).toBe(307); // Proxy uses 307
const location = response?.headers.get("location");
expect(location).toContain("/redirect");
@@ -563,7 +567,7 @@ describe("middleware redirects with Facebook Pixel tracking", () => {
});
test("uses direct redirect when Facebook Pixel is disabled", async () => {
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () =>
@@ -578,7 +582,7 @@ describe("middleware redirects with Facebook Pixel tracking", () => {
});
const req = createRequest("/pilot");
- const response = await middleware(req);
+ const response = await proxy(req);
expect(response).toBeInstanceOf(NextResponse);
expect(response?.status).toBe(307);
@@ -589,7 +593,7 @@ describe("middleware redirects with Facebook Pixel tracking", () => {
});
test("preserves UTM parameters when redirecting to tracking page", async () => {
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () =>
@@ -610,7 +614,7 @@ describe("middleware redirects with Facebook Pixel tracking", () => {
});
const req = createRequest("/pilot");
- const response = await middleware(req);
+ const response = await proxy(req);
const location = response?.headers.get("location");
expect(location).toContain("/redirect");
@@ -622,7 +626,7 @@ describe("middleware redirects with Facebook Pixel tracking", () => {
});
test("handles Facebook Pixel with only source parameter", async () => {
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () =>
@@ -638,7 +642,7 @@ describe("middleware redirects with Facebook Pixel tracking", () => {
});
const req = createRequest("/pilot");
- const response = await middleware(req);
+ const response = await proxy(req);
const location = response?.headers.get("location");
expect(location).toContain("/redirect");
@@ -649,7 +653,7 @@ describe("middleware redirects with Facebook Pixel tracking", () => {
test("handles Facebook Pixel when mapper fails gracefully", async () => {
// Mock Notion response that will cause mapper to fail
- (global.fetch as jest.Mock)
+ (global.fetch as ReturnType)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
@@ -678,7 +682,7 @@ describe("middleware redirects with Facebook Pixel tracking", () => {
});
const req = createRequest("/pilot");
- const response = await middleware(req);
+ const response = await proxy(req);
// Should fall back to direct redirect
expect(response).toBeInstanceOf(NextResponse);
diff --git a/src/proxy.__tests__.ts b/src/proxy.__tests__.ts
new file mode 100644
index 00000000..e945f13f
--- /dev/null
+++ b/src/proxy.__tests__.ts
@@ -0,0 +1,689 @@
+/**
+ * Tests for proxy redirect functionality with UTM parameters
+ */
+
+import { proxy } from "@/proxy";
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
+
+// Mock fetch globally
+global.fetch = jest.fn();
+
+// Mock environment variables
+const mockEnv: NodeJS.ProcessEnv = {
+ NODE_ENV: "test" as const,
+ NOTION_KEY: "test-notion-key",
+ NOTION_REDIRECTS_ID: "test-db-id",
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ // Assign individual properties (NODE_ENV is read-only, skip it)
+ process.env.NOTION_KEY = mockEnv.NOTION_KEY;
+ process.env.NOTION_REDIRECTS_ID = mockEnv.NOTION_REDIRECTS_ID;
+});
+
+afterEach(() => {
+ process.env.ALLOW_INCOMING_UTM = undefined;
+});
+
+describe("proxy redirects with UTM parameters", () => {
+ const createRequest = (
+ pathname: string,
+ searchParams?: URLSearchParams,
+ ): NextRequest => {
+ const url = new URL(`https://example.com${pathname}`);
+ if (searchParams) {
+ searchParams.forEach((value, key) => {
+ url.searchParams.set(key, value);
+ });
+ }
+ return {
+ nextUrl: url,
+ headers: new Headers(),
+ } as NextRequest;
+ };
+
+ const createNotionResponse = (
+ destination: string,
+ utmParams?: {
+ utm_source?: string;
+ utm_campaign?: string;
+ utm_medium?: string;
+ utm_content?: string;
+ utm_term?: string;
+ utm_offer?: string;
+ utm_id?: string;
+ utm_campaign_relation?: string;
+ },
+ ) => {
+ const properties: Record = {
+ Destination: {
+ type: "url",
+ url: destination,
+ },
+ "Redirects (Calls)": {
+ type: "number",
+ number: 0,
+ },
+ };
+
+ if (utmParams?.utm_source) {
+ properties["UTM Source"] = {
+ type: "select",
+ select: { name: utmParams.utm_source },
+ };
+ }
+
+ if (utmParams?.utm_campaign_relation) {
+ properties["UTM Campaign (Relation)"] = {
+ type: "select",
+ select: { name: utmParams.utm_campaign_relation },
+ };
+ } else if (utmParams?.utm_campaign) {
+ properties["UTM Campaign"] = {
+ type: "select",
+ select: { name: utmParams.utm_campaign },
+ };
+ }
+
+ if (utmParams?.utm_medium) {
+ properties["UTM Medium"] = {
+ type: "select",
+ select: { name: utmParams.utm_medium },
+ };
+ }
+
+ if (utmParams?.utm_content) {
+ properties["UTM Content"] = {
+ type: "select",
+ select: { name: utmParams.utm_content },
+ };
+ }
+
+ if (utmParams?.utm_term) {
+ properties["UTM Term"] = {
+ type: "select",
+ select: { name: utmParams.utm_term },
+ };
+ }
+
+ if (utmParams?.utm_offer) {
+ properties["UTM Offer"] = {
+ type: "select",
+ select: { name: utmParams.utm_offer },
+ };
+ }
+
+ if (utmParams?.utm_id) {
+ properties["UTM Id"] = {
+ type: "select",
+ select: { name: utmParams.utm_id },
+ };
+ }
+
+ return {
+ results: [
+ {
+ id: "test-page-id",
+ properties,
+ },
+ ],
+ };
+ };
+
+ test("redirects with all UTM parameters from Notion", async () => {
+ const utmParams = {
+ utm_source: "linkedin",
+ utm_campaign: "beta2025",
+ utm_medium: "social",
+ utm_content: "post-123",
+ utm_term: "keyword",
+ utm_offer: "early-access",
+ utm_id: "campaign-456",
+ };
+
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () =>
+ createNotionResponse("https://example.com/target", utmParams),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const req = createRequest("/pilot");
+ const response = await proxy(req);
+
+ expect(response).toBeInstanceOf(NextResponse);
+ expect(response?.status).toBe(307);
+
+ const location = response?.headers.get("location");
+ expect(location).toContain("https://example.com/target");
+ expect(location).toContain("utm_source=linkedin");
+ expect(location).toContain("utm_campaign=beta2025");
+ expect(location).toContain("utm_medium=social");
+ expect(location).toContain("utm_content=post-123");
+ expect(location).toContain("utm_term=keyword");
+ expect(location).toContain("utm_offer=early-access");
+ expect(location).toContain("utm_id=campaign-456");
+ });
+
+ test("prefers UTM Campaign (Relation) over UTM Campaign", async () => {
+ const utmParams = {
+ utm_campaign: "fallback-campaign",
+ utm_campaign_relation: "primary-campaign",
+ };
+
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () =>
+ createNotionResponse("https://example.com/target", utmParams),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const req = createRequest("/pilot");
+ const response = await proxy(req);
+
+ const location = response?.headers.get("location");
+ expect(location).toContain("utm_campaign=primary-campaign");
+ expect(location).not.toContain("utm_campaign=fallback-campaign");
+ });
+
+ test("falls back to UTM Campaign when UTM Campaign (Relation) is missing", async () => {
+ const utmParams = {
+ utm_campaign: "fallback-campaign",
+ };
+
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () =>
+ createNotionResponse("https://example.com/target", utmParams),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const req = createRequest("/pilot");
+ const response = await proxy(req);
+
+ const location = response?.headers.get("location");
+ expect(location).toContain("utm_campaign=fallback-campaign");
+ });
+
+ test("removes existing UTM parameters from destination URL before adding Notion UTMs", async () => {
+ const utmParams = {
+ utm_source: "notion-source",
+ utm_campaign: "notion-campaign",
+ };
+
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () =>
+ createNotionResponse(
+ "https://example.com/target?utm_source=old&utm_campaign=old",
+ utmParams,
+ ),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const req = createRequest("/pilot");
+ const response = await proxy(req);
+
+ const location = response?.headers.get("location");
+ expect(location).toContain("utm_source=notion-source");
+ expect(location).toContain("utm_campaign=notion-campaign");
+ expect(location).not.toContain("utm_source=old");
+ expect(location).not.toContain("utm_campaign=old");
+ });
+
+ test("preserves incoming UTM parameters when ALLOW_INCOMING_UTM is set", async () => {
+ process.env.ALLOW_INCOMING_UTM = "1";
+
+ const utmParams = {
+ utm_source: "notion-source",
+ utm_campaign: "notion-campaign",
+ };
+
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () =>
+ createNotionResponse("https://example.com/target", utmParams),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const searchParams = new URLSearchParams();
+ searchParams.set("utm_source", "incoming-source");
+ searchParams.set("utm_content", "incoming-content");
+
+ const req = createRequest("/pilot", searchParams);
+ const response = await proxy(req);
+
+ const location = response?.headers.get("location");
+ // Incoming UTMs override Notion UTMs when ALLOW_INCOMING_UTM is set
+ expect(location).toContain("utm_source=incoming-source");
+ expect(location).toContain("utm_content=incoming-content");
+ });
+
+ test("ignores incoming UTM parameters when ALLOW_INCOMING_UTM is not set", async () => {
+ process.env.ALLOW_INCOMING_UTM = undefined;
+
+ const utmParams = {
+ utm_source: "notion-source",
+ utm_campaign: "notion-campaign",
+ };
+
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () =>
+ createNotionResponse("https://example.com/target", utmParams),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const searchParams = new URLSearchParams();
+ searchParams.set("utm_source", "incoming-source");
+
+ const req = createRequest("/pilot", searchParams);
+ const response = await proxy(req);
+
+ const location = response?.headers.get("location");
+ // Notion UTMs should be used, incoming UTMs ignored
+ expect(location).toContain("utm_source=notion-source");
+ expect(location).not.toContain("utm_source=incoming-source");
+ });
+
+ test("adds RedirectSource parameter based on referer", async () => {
+ const utmParams = {
+ utm_source: "linktree",
+ };
+
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () =>
+ createNotionResponse("https://example.com/target", utmParams),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const req = createRequest("/pilot");
+ req.headers.set("referer", "https://example.com/linktree");
+
+ const response = await proxy(req);
+
+ const location = response?.headers.get("location");
+ expect(location).toContain("RedirectSource=Linktree");
+ });
+
+ test("sets RedirectSource to Direct when not from linktree", async () => {
+ const utmParams = {
+ utm_source: "linkedin",
+ };
+
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () =>
+ createNotionResponse("https://example.com/target", utmParams),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const req = createRequest("/pilot");
+ const response = await proxy(req);
+
+ const location = response?.headers.get("location");
+ expect(location).toContain("RedirectSource=Direct");
+ });
+
+ test("handles relative path redirects", async () => {
+ const utmParams = {
+ utm_source: "internal",
+ utm_campaign: "internal-campaign",
+ };
+
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => createNotionResponse("/signup", utmParams),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const req = createRequest("/pilot");
+ const response = await proxy(req);
+
+ const location = response?.headers.get("location");
+ expect(location).toMatch(/^https:\/\/example\.com\/signup/);
+ expect(location).toContain("utm_source=internal");
+ expect(location).toContain("utm_campaign=internal-campaign");
+ });
+
+ test("increments Redirects (Calls) counter in Notion", async () => {
+ const utmParams = {
+ utm_source: "test",
+ };
+
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ results: [
+ {
+ id: "test-page-id",
+ properties: {
+ Destination: {
+ type: "url",
+ url: "https://example.com/target",
+ },
+ "Redirects (Calls)": {
+ type: "number",
+ number: 5,
+ },
+ },
+ },
+ ],
+ }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const req = createRequest("/pilot");
+ await proxy(req);
+
+ // Verify increment call was made
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ const incrementCall = (global.fetch as jest.Mock).mock.calls[1];
+ expect(incrementCall[0]).toBe(
+ "https://api.notion.com/v1/pages/test-page-id",
+ );
+ expect(incrementCall[1]?.method).toBe("PATCH");
+ expect(incrementCall[1]?.body).toContain('"number":6');
+ });
+
+ test("skips redirect for paths that should be ignored", async () => {
+ const paths = [
+ "/_next/static/chunk.js",
+ "/api/health",
+ "/linktree",
+ "/favicon.ico",
+ "/assets/image.png",
+ "/style.css",
+ ];
+
+ for (const path of paths) {
+ const req = createRequest(path);
+ const response = await proxy(req);
+ expect(response).toBeInstanceOf(NextResponse);
+ expect(response?.status).toBe(200);
+ }
+ });
+});
+
+describe("proxy redirects with Facebook Pixel tracking", () => {
+ const createRequest = (
+ pathname: string,
+ searchParams?: URLSearchParams,
+ ): NextRequest => {
+ const url = new URL(`https://example.com${pathname}`);
+ if (searchParams) {
+ searchParams.forEach((value, key) => {
+ url.searchParams.set(key, value);
+ });
+ }
+ return {
+ nextUrl: url,
+ headers: new Headers(),
+ } as NextRequest;
+ };
+
+ const createNotionResponseWithFacebookPixel = (
+ destination: string,
+ facebookPixelEnabled: boolean,
+ facebookPixelSource?: string,
+ facebookPixelIntent?: string,
+ utmParams?: {
+ utm_source?: string;
+ utm_campaign?: string;
+ },
+ ) => {
+ const properties: Record = {
+ Destination: {
+ type: "url",
+ url: destination,
+ },
+ "Redirects (Calls)": {
+ type: "number",
+ number: 0,
+ },
+ "Facebook Pixel Enabled": facebookPixelEnabled
+ ? {
+ type: "select",
+ select: { name: "True" },
+ }
+ : {
+ type: "select",
+ select: { name: "False" },
+ },
+ ...(facebookPixelSource && {
+ "Facebook Pixel Source": {
+ type: "select",
+ select: { name: facebookPixelSource },
+ },
+ }),
+ ...(facebookPixelIntent && {
+ "Facebook Pixel Intent": {
+ type: "rich_text",
+ rich_text: [{ plain_text: facebookPixelIntent }],
+ },
+ }),
+ };
+
+ if (utmParams?.utm_source) {
+ properties["UTM Source"] = {
+ type: "select",
+ select: { name: utmParams.utm_source },
+ };
+ }
+
+ if (utmParams?.utm_campaign) {
+ properties["UTM Campaign"] = {
+ type: "select",
+ select: { name: utmParams.utm_campaign },
+ };
+ }
+
+ return {
+ results: [
+ {
+ id: "test-page-id",
+ properties,
+ },
+ ],
+ };
+ };
+
+ test("redirects to client-side tracking page when Facebook Pixel is enabled", async () => {
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () =>
+ createNotionResponseWithFacebookPixel(
+ "https://example.com/target",
+ true,
+ "Meta campaign",
+ "MVP_Launch_BlackFriday",
+ ),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const req = createRequest("/pilot");
+ const response = await proxy(req);
+
+ expect(response).toBeInstanceOf(NextResponse);
+ expect(response?.status).toBe(307); // Proxy uses 307
+
+ const location = response?.headers.get("location");
+ expect(location).toContain("/redirect");
+ expect(location).toContain("to=https%3A%2F%2Fexample.com%2Ftarget");
+ // URL encoding can use + or %20 for spaces
+ expect(location).toMatch(/fbSource=Meta[\s+%20]campaign/);
+ expect(location).toContain("fbIntent=MVP_Launch_BlackFriday");
+ });
+
+ test("uses direct redirect when Facebook Pixel is disabled", async () => {
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () =>
+ createNotionResponseWithFacebookPixel(
+ "https://example.com/target",
+ false,
+ ),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const req = createRequest("/pilot");
+ const response = await proxy(req);
+
+ expect(response).toBeInstanceOf(NextResponse);
+ expect(response?.status).toBe(307);
+
+ const location = response?.headers.get("location");
+ expect(location).toContain("https://example.com/target");
+ expect(location).not.toContain("/redirect");
+ });
+
+ test("preserves UTM parameters when redirecting to tracking page", async () => {
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () =>
+ createNotionResponseWithFacebookPixel(
+ "https://example.com/target",
+ true,
+ "Meta campaign",
+ "Launch",
+ {
+ utm_source: "linkedin",
+ utm_campaign: "beta2025",
+ },
+ ),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const req = createRequest("/pilot");
+ const response = await proxy(req);
+
+ const location = response?.headers.get("location");
+ expect(location).toContain("/redirect");
+ // URL encoding can use + or %20 for spaces
+ expect(location).toMatch(/fbSource=Meta[\s+%20]campaign/);
+ // UTM params should be in the 'to' parameter (URL-encoded, = becomes %3D)
+ expect(location).toMatch(/utm_source%3Dlinkedin|utm_source=linkedin/);
+ expect(location).toMatch(/utm_campaign%3Dbeta2025|utm_campaign=beta2025/);
+ });
+
+ test("handles Facebook Pixel with only source parameter", async () => {
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () =>
+ createNotionResponseWithFacebookPixel(
+ "https://example.com/target",
+ true,
+ "Meta campaign",
+ ),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const req = createRequest("/pilot");
+ const response = await proxy(req);
+
+ const location = response?.headers.get("location");
+ expect(location).toContain("/redirect");
+ // URL encoding can use + or %20 for spaces
+ expect(location).toMatch(/fbSource=Meta[\s+%20]campaign/);
+ expect(location).not.toContain("fbIntent");
+ });
+
+ test("handles Facebook Pixel when mapper fails gracefully", async () => {
+ // Mock Notion response that will cause mapper to fail
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ results: [
+ {
+ id: "test-page-id",
+ properties: {
+ Destination: {
+ type: "url",
+ url: "https://example.com/target",
+ },
+ "Redirects (Calls)": {
+ type: "number",
+ number: 0,
+ },
+ // Invalid structure that might cause mapper issues
+ "Facebook Pixel Enabled": null,
+ },
+ },
+ ],
+ }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const req = createRequest("/pilot");
+ const response = await proxy(req);
+
+ // Should fall back to direct redirect
+ expect(response).toBeInstanceOf(NextResponse);
+ const location = response?.headers.get("location");
+ expect(location).toContain("https://example.com/target");
+ expect(location).not.toContain("/redirect");
+ });
+});
diff --git a/src/middleware.ts b/src/proxy.ts
similarity index 92%
rename from src/middleware.ts
rename to src/proxy.ts
index 49eb8bd9..4fb9f1fe 100644
--- a/src/middleware.ts
+++ b/src/proxy.ts
@@ -1,7 +1,8 @@
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
+
import { mapNotionPageToLinkTree } from "@/utils/notion/linktreeMapper";
import type { NotionPage } from "@/utils/notion/notionTypes";
-import { NextResponse } from "next/server";
-import type { NextRequest } from "next/server";
type Found = {
destination: string;
@@ -43,7 +44,7 @@ function sanitizeUrlLike(input: string | undefined | null): string {
function pickProp(props: Record, aliases: string[]): unknown {
// 1) Exact alias match
for (const a of aliases) {
- if (Object.prototype.hasOwnProperty.call(props, a)) return props[a];
+ if (Object.hasOwn(props, a)) return props[a];
}
// 2) Case-insensitive exact
const lowerMap = new Map();
@@ -183,9 +184,9 @@ function getDestinationStrict(prop: unknown): string | undefined {
}
async function findRedirectBySlug(slug: string): Promise {
- console.log(`[middleware] findRedirectBySlug searching for: '${slug}'`);
+ console.log(`[proxy] findRedirectBySlug searching for: '${slug}'`);
// Prefer Notion when credentials exist (even in development) so we can increment counters.
- const isProd = process.env.NODE_ENV === "production";
+ const _isProd = process.env.NODE_ENV === "production";
const NOTION_KEY = process.env.NOTION_KEY;
const DB_ID = process.env.NOTION_REDIRECTS_ID;
const devFallback = (() => {
@@ -305,10 +306,7 @@ async function findRedirectBySlug(slug: string): Promise {
facebookPixelIntent = mapped.facebookPixelIntent;
} catch (err) {
// If mapping fails, continue without Facebook Pixel tracking
- console.error(
- "[middleware] Failed to extract Facebook Pixel fields:",
- err,
- );
+ console.error("[proxy] Failed to extract Facebook Pixel fields:", err);
}
const result: Found = {
@@ -328,18 +326,18 @@ async function findRedirectBySlug(slug: string): Promise {
facebookPixelIntent,
};
console.log(
- `[middleware] Notion found pageId: ${result.pageId}, nextCalls: ${result.nextCalls}`,
+ `[proxy] Notion found pageId: ${result.pageId}, nextCalls: ${result.nextCalls}`,
);
return result;
}
}
// Not found in Notion; use dev fallback if available (no counter increment)
if (devFallback)
- console.log(`[middleware] Notion miss, using dev fallback for '${slug}'`);
+ console.log(`[proxy] Notion miss, using dev fallback for '${slug}'`);
return devFallback ? { destination: devFallback } : null;
}
-export async function middleware(req: NextRequest) {
+export async function proxy(req: NextRequest) {
const { pathname } = req.nextUrl;
// Skip Next.js internals and API/static routes
if (
@@ -358,17 +356,17 @@ export async function middleware(req: NextRequest) {
const slug = pathname.split("/")[1]?.toLowerCase();
if (!slug) return NextResponse.next();
- console.log(`[middleware] Path: ${pathname}, Slug: ${slug}`);
+ console.log(`[proxy] Path: ${pathname}, Slug: ${slug}`);
try {
const found = await findRedirectBySlug(slug);
- console.log("[middleware] findRedirectBySlug result:", found);
+ console.log("[proxy] findRedirectBySlug result:", found);
if (!found) return NextResponse.next();
let dest = sanitizeUrlLike(found.destination || "");
if (dest.length < 3) {
console.warn(
- "[middleware] Weak destination for slug:",
+ "[proxy] Weak destination for slug:",
slug,
JSON.stringify(dest),
);
@@ -385,7 +383,7 @@ export async function middleware(req: NextRequest) {
else {
// Suspicious destination like single letter; skip redirect
console.warn(
- "[middleware] Ignoring suspicious destination for slug:",
+ "[proxy] Ignoring suspicious destination for slug:",
slug,
JSON.stringify(dest),
);
@@ -408,7 +406,7 @@ export async function middleware(req: NextRequest) {
if (!isRelative && !isValidAbsoluteHttpUrl(dest)) {
console.warn(
- "[middleware] Malformed absolute URL, skipping redirect:",
+ "[proxy] Malformed absolute URL, skipping redirect:",
JSON.stringify(dest),
);
return NextResponse.next();
@@ -433,7 +431,7 @@ export async function middleware(req: NextRequest) {
url.searchParams.set("utm_redirect_url", found.utm_redirect_url);
// Debug: show the UTMs we are about to use (helps verify Notion -> URL mapping)
- console.log("[middleware] UTMs (after Notion + optional overrides):", {
+ console.log("[proxy] UTMs (after Notion + optional overrides):", {
source: url.searchParams.get("utm_source"),
campaign: url.searchParams.get("utm_campaign"),
medium: url.searchParams.get("utm_medium"),
@@ -477,7 +475,7 @@ export async function middleware(req: NextRequest) {
}
}
- console.log("[middleware] Final redirect:", url.toString());
+ console.log("[proxy] Final redirect:", url.toString());
// Add RedirectSource based on referer
const referer = req.headers.get("referer");
@@ -511,7 +509,7 @@ export async function middleware(req: NextRequest) {
const nextCalls = found.nextCalls;
if (NOTION_KEY && pageId && typeof nextCalls === "number") {
console.log(
- `[middleware] Attempting to increment count for pageId: ${pageId} to ${nextCalls}`,
+ `[proxy] Attempting to increment count for pageId: ${pageId} to ${nextCalls}`,
);
// Fire-and-forget, but log errors
(async () => {
@@ -532,17 +530,17 @@ export async function middleware(req: NextRequest) {
if (!res.ok) {
const errorBody = await res.json();
console.error(
- `[middleware] FAILED to increment count for pageId: ${pageId}. Status: ${res.status}`,
+ `[proxy] FAILED to increment count for pageId: ${pageId}. Status: ${res.status}`,
JSON.stringify(errorBody, null, 2),
);
} else {
console.log(
- `[middleware] Successfully incremented count for pageId: ${pageId}. Status: ${res.status}`,
+ `[proxy] Successfully incremented count for pageId: ${pageId}. Status: ${res.status}`,
);
}
} catch (e) {
console.error(
- `[middleware] Network error while incrementing count for pageId: ${pageId}`,
+ `[proxy] Network error while incrementing count for pageId: ${pageId}`,
e,
);
}
@@ -550,7 +548,7 @@ export async function middleware(req: NextRequest) {
}
return NextResponse.redirect(url);
- } catch (error) {
+ } catch (_error) {
// Fail open
return NextResponse.next();
}