Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
31dd060
feat(api): add supabase helpers for post_comments, social_posts, and …
arpitgupta1214 Apr 24, 2026
9edcbac
feat(api): port apify webhook chain + instagram scrape handlers
arpitgupta1214 Apr 24, 2026
476c98e
fix(api): apify webhook URL, field-wipe, tests, default exports
arpitgupta1214 Apr 24, 2026
8b6e93d
refactor(api): tighten apify chain types + fix profile_url round-trip
arpitgupta1214 Apr 24, 2026
82e02c6
refactor(api): address cubic feedback on apify chain
arpitgupta1214 Apr 24, 2026
5a7b3b0
Merge remote-tracking branch 'origin/test' into migrate/apify-webhook…
sweetmantech Apr 27, 2026
ad297ae
refactor(api): address sweetman feedback on apify chain (PR #483)
claude Apr 27, 2026
beb9197
refactor(networking): make getBaseUrl prod-aware and skip apify webho…
claude Apr 28, 2026
f0a1d9d
refactor(apify): align webhook validator with project pattern
claude Apr 28, 2026
7dec06f
refactor(apify): use request validator + drop dispatcher fallback shape
claude Apr 28, 2026
1bdc34c
refactor(apify): rename postApifyWebhookHandler -> postApifyHandler
claude Apr 28, 2026
f910b3d
refactor(apify): rename postApifyHandler -> apifyWebhookHandler
claude Apr 28, 2026
adbbd4a
refactor(apify): use SDK listItems() in getDataset
claude Apr 28, 2026
8c26de2
refactor(apify): drop getDataset wrapper, call SDK directly
claude Apr 28, 2026
612b7f9
refactor(apify): prune redundancies in webhook chain
claude Apr 28, 2026
6b7294d
refactor(apify): simplify instagram helpers
claude Apr 28, 2026
175370b
fix(security): address SSRF + review feedback on apify chain
claude Apr 28, 2026
93c4368
fix(apify): isolate email + follow-up scrape side effects
claude Apr 28, 2026
8a06631
refactor(apify): inline saveApifyInstagramComments into its single ca…
claude Apr 28, 2026
20a7646
fix(apify): resolve TS errors blocking Vercel build
claude Apr 28, 2026
3b921ab
Merge remote-tracking branch 'origin/test' into migrate/apify-webhook…
sweetmantech Apr 30, 2026
a23ae1b
refactor(apify): inline cross-table orchestrator + rename insertSocia…
claude Apr 30, 2026
79a0b25
refactor(apify): collapse two-query post-comments check into one
claude Apr 30, 2026
2b2407c
refactor(supabase): always include post_comments embed in getPosts
claude Apr 30, 2026
1f9ed1e
fix(apify): address coderabbit review feedback batch
claude Apr 30, 2026
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
19 changes: 19 additions & 0 deletions app/api/apify/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { apifyWebhookHandler } from "@/lib/apify/apifyWebhookHandler";

export const maxDuration = 60;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;

/**
* `POST /api/apify` — Apify webhook receiver. Unauthenticated on the
* route itself; authenticity is established by the shared actor token
* that only trusted runs can register. Delegates to the dispatcher.
*
* @param request - Incoming webhook request.
* @returns JSON response describing the processed payload (always 200).
*/
export async function POST(request: NextRequest) {
return apifyWebhookHandler(request);
}
104 changes: 104 additions & 0 deletions lib/apify/__tests__/apifyWebhookHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { apifyWebhookHandler } from "../apifyWebhookHandler";
import { handleInstagramProfileScraperResults } from "../instagram/handleInstagramProfileScraperResults";
import { handleInstagramCommentsScraper } from "../instagram/handleInstagramCommentsScraper";

vi.mock("../instagram/handleInstagramProfileScraperResults", () => ({
handleInstagramProfileScraperResults: vi.fn(),
}));
vi.mock("../instagram/handleInstagramCommentsScraper", () => ({
handleInstagramCommentsScraper: vi.fn(),
}));

function makeRequest(body: unknown, raw?: string) {
return new NextRequest("http://localhost/api/apify", {
method: "POST",
headers: { "content-type": "application/json" },
body: raw ?? JSON.stringify(body),
});
}

const baseBody = {
userId: "u",
createdAt: "2026-01-01T00:00:00Z",
eventType: "ACTOR.RUN.SUCCEEDED",
resource: { defaultDatasetId: "ds_1" },
};

describe("apifyWebhookHandler", () => {
beforeEach(() => vi.clearAllMocks());

it("dispatches profile scraper results for the IG profile actor", async () => {
vi.mocked(handleInstagramProfileScraperResults).mockResolvedValue({ posts: [1, 2] } as never);

const res = await apifyWebhookHandler(
makeRequest({ ...baseBody, eventData: { actorId: "dSCLg0C3YEZ83HzYX" } }),
);

expect(res.status).toBe(200);
expect(await res.json()).toEqual({ posts: [1, 2] });
expect(handleInstagramProfileScraperResults).toHaveBeenCalledOnce();
expect(handleInstagramCommentsScraper).not.toHaveBeenCalled();
});

it("dispatches comments scraper for the IG comments actor", async () => {
vi.mocked(handleInstagramCommentsScraper).mockResolvedValue({
comments: [],
processedPostUrls: ["u1", "u2"],
} as never);

const res = await apifyWebhookHandler(
makeRequest({ ...baseBody, eventData: { actorId: "SbK00X0JYCPblD2wp" } }),
);

expect(res.status).toBe(200);
expect(await res.json()).toEqual({ comments: [], processedPostUrls: ["u1", "u2"] });
expect(handleInstagramCommentsScraper).toHaveBeenCalledOnce();
expect(handleInstagramProfileScraperResults).not.toHaveBeenCalled();
});

it("returns a 200 error response for unhandled actor ids", async () => {
const res = await apifyWebhookHandler(
makeRequest({ ...baseBody, eventData: { actorId: "unknown_actor" } }),
);

expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("error");
expect(body.error).toContain("unknown_actor");
expect(handleInstagramProfileScraperResults).not.toHaveBeenCalled();
expect(handleInstagramCommentsScraper).not.toHaveBeenCalled();
});

it("returns a 200 error response when the dispatched handler throws", async () => {
vi.mocked(handleInstagramProfileScraperResults).mockRejectedValue(new Error("boom"));

const res = await apifyWebhookHandler(
makeRequest({ ...baseBody, eventData: { actorId: "dSCLg0C3YEZ83HzYX" } }),
);

expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ status: "error", error: "Internal server error" });
});

it("propagates the validator's error response for invalid payloads", async () => {
const res = await apifyWebhookHandler(makeRequest({ bogus: true }));

expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("error");
expect(typeof body.error).toBe("string");
expect(handleInstagramProfileScraperResults).not.toHaveBeenCalled();
expect(handleInstagramCommentsScraper).not.toHaveBeenCalled();
});

it("propagates the validator's Invalid JSON response for malformed bodies", async () => {
const res = await apifyWebhookHandler(makeRequest(null, "not json"));

expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ status: "error", error: "Invalid JSON" });
});
});
46 changes: 46 additions & 0 deletions lib/apify/__tests__/getApifyWebhooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getApifyWebhooks } from "../getApifyWebhooks";

describe("getApifyWebhooks", () => {
const originalEnv = process.env;

beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
});

afterEach(() => {
process.env = originalEnv;
});

it("returns the prod-aliased webhook URL when VERCEL_ENV is production", () => {
process.env.VERCEL_ENV = "production";
process.env.VERCEL_URL = "recoup-api-abc.vercel.app";

expect(getApifyWebhooks()).toEqual([
{
eventTypes: ["ACTOR.RUN.SUCCEEDED"],
requestUrl: "https://recoup-api.vercel.app/api/apify",
},
]);
});

it("returns the deployment-specific URL on preview deploys", () => {
delete process.env.VERCEL_ENV;
process.env.VERCEL_URL = "preview-xyz.vercel.app";

expect(getApifyWebhooks()).toEqual([
{
eventTypes: ["ACTOR.RUN.SUCCEEDED"],
requestUrl: "https://preview-xyz.vercel.app/api/apify",
},
]);
});

it("returns no webhooks locally so we don't hand Apify an unreachable URL", () => {
delete process.env.VERCEL_ENV;
delete process.env.VERCEL_URL;

expect(getApifyWebhooks()).toEqual([]);
});
});
55 changes: 55 additions & 0 deletions lib/apify/__tests__/validateApifyWebhookRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { validateApifyWebhookRequest } from "../validateApifyWebhookRequest";

function makeRequest(body: unknown, raw?: string) {
return new NextRequest("http://localhost/api/apify", {
method: "POST",
headers: { "content-type": "application/json" },
body: raw ?? JSON.stringify(body),
});
}

describe("validateApifyWebhookRequest", () => {
it("returns the parsed payload for a valid Apify request", async () => {
const result = await validateApifyWebhookRequest(
makeRequest({
userId: "u",
createdAt: "2026-01-01T00:00:00Z",
eventType: "ACTOR.RUN.SUCCEEDED",
eventData: { actorId: "dSCLg0C3YEZ83HzYX" },
resource: { defaultDatasetId: "ds_1" },
}),
);

expect(result).not.toBeInstanceOf(NextResponse);
if (!(result instanceof NextResponse)) {
expect(result.eventData.actorId).toBe("dSCLg0C3YEZ83HzYX");
expect(result.resource.defaultDatasetId).toBe("ds_1");
}
});

it("returns a 200 NextResponse with the project error shape on missing fields", async () => {
const result = await validateApifyWebhookRequest(makeRequest({ eventData: {}, resource: {} }));

expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) {
expect(result.status).toBe(200);
const body = await result.json();
expect(body.status).toBe("error");
expect(Array.isArray(body.missing_fields)).toBe(true);
expect(typeof body.error).toBe("string");
}
});

it("returns a 200 NextResponse with Invalid JSON for malformed bodies", async () => {
const result = await validateApifyWebhookRequest(makeRequest(null, "not json"));

expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) {
expect(result.status).toBe(200);
const body = await result.json();
expect(body).toEqual({ status: "error", error: "Invalid JSON" });
}
});
});
44 changes: 44 additions & 0 deletions lib/apify/apifyWebhookHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { validateApifyWebhookRequest } from "@/lib/apify/validateApifyWebhookRequest";
import { handleInstagramProfileScraperResults } from "@/lib/apify/instagram/handleInstagramProfileScraperResults";
import { handleInstagramCommentsScraper } from "@/lib/apify/instagram/handleInstagramCommentsScraper";

const INSTAGRAM_PROFILE_ACTOR_ID = "dSCLg0C3YEZ83HzYX";
const INSTAGRAM_COMMENTS_ACTOR_ID = "SbK00X0JYCPblD2wp";

/**
* Handler for `POST /api/apify`. Always responds 200 so Apify does not
* retry on our side of a failure — malformed payloads, unknown actors,
* and downstream errors are logged and surfaced as a `status: "error"`
* JSON body.
*
* @param request - Incoming webhook request.
*/
export async function apifyWebhookHandler(request: NextRequest): Promise<NextResponse> {
const validated = await validateApifyWebhookRequest(request);
if (validated instanceof NextResponse) return validated;

const { actorId } = validated.eventData;

try {
switch (actorId) {
case INSTAGRAM_PROFILE_ACTOR_ID: {
const result = await handleInstagramProfileScraperResults(validated);
return NextResponse.json(result, { status: 200 });
}
case INSTAGRAM_COMMENTS_ACTOR_ID: {
const result = await handleInstagramCommentsScraper(validated);
return NextResponse.json(result, { status: 200 });
}
default:
console.warn(`[WARN] apifyWebhookHandler: unhandled actorId ${actorId}`);
return NextResponse.json(
{ status: "error", error: `Unhandled actorId: ${actorId}` },
{ status: 200 },
);
}
} catch (error) {
console.error("[ERROR] apifyWebhookHandler:", error);
return NextResponse.json({ status: "error", error: "Internal server error" }, { status: 200 });
}
}
24 changes: 24 additions & 0 deletions lib/apify/getApifyWebhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { WebhookEventType, WebhookUpdateData } from "apify-client";
import { getBaseUrl } from "@/lib/networking/getBaseUrl";

/**
* Webhook config registered on Apify actor runs. The Apify client
* serializes this to base64 before including it on the start request.
* Points at this service's `POST /api/apify` webhook receiver.
*
* Returns an empty list in local dev (when the resolved base URL is
* localhost) — Apify cannot reach a developer's machine, so the run
* still kicks off but without a useless callback URL attached.
*/
export function getApifyWebhooks(): WebhookUpdateData[] {
const baseUrl = getBaseUrl();
if (baseUrl.startsWith("http://localhost")) return [];

const eventTypes: WebhookEventType[] = ["ACTOR.RUN.SUCCEEDED"];
return [
{
eventTypes,
requestUrl: `${baseUrl}/api/apify`,
},
];
}
43 changes: 43 additions & 0 deletions lib/apify/instagram/__tests__/getOrCreatePostsForComments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getOrCreatePostsForComments } from "../getOrCreatePostsForComments";
import { upsertPosts } from "@/lib/supabase/posts/upsertPosts";
import { getPosts } from "@/lib/supabase/posts/getPosts";

vi.mock("@/lib/supabase/posts/upsertPosts", () => ({ upsertPosts: vi.fn() }));
vi.mock("@/lib/supabase/posts/getPosts", () => ({ getPosts: vi.fn() }));

describe("getOrCreatePostsForComments", () => {
beforeEach(() => vi.clearAllMocks());

it("returns empty map for empty input and does not touch DB", async () => {
const result = await getOrCreatePostsForComments([]);
expect(result.size).toBe(0);
expect(upsertPosts).not.toHaveBeenCalled();
expect(getPosts).not.toHaveBeenCalled();
});

it("inserts only the URLs missing from Supabase", async () => {
const existing = [{ id: "p1", post_url: "u1" }] as never;
const afterInsert = [
{ id: "p1", post_url: "u1" },
{ id: "p2", post_url: "u2" },
] as never;

vi.mocked(getPosts).mockResolvedValueOnce(existing).mockResolvedValueOnce(afterInsert);

const result = await getOrCreatePostsForComments(["u1", "u2", "u2"]);

expect(upsertPosts).toHaveBeenCalledWith([expect.objectContaining({ post_url: "u2" })]);
expect(result.get("u1")).toEqual(existing[0]);
expect(result.get("u2")).toEqual(afterInsert[1]);
});

it("skips insert when all posts already exist", async () => {
const existing = [{ id: "p1", post_url: "u1" }] as never;
vi.mocked(getPosts).mockResolvedValue(existing);

await getOrCreatePostsForComments(["u1"]);

expect(upsertPosts).not.toHaveBeenCalled();
});
});
Loading
Loading