Skip to content
Merged
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
50 changes: 50 additions & 0 deletions __tests__/comment-reconciler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Comment reconciliation β€” ad copies of a boosted post.
*
* Comments left on an ad carry the ad's own media id, so the sweep has to look
* at those media too or a webhook Meta never delivers is lost for good.
*/

import { beforeEach, describe, expect, it, vi } from "vitest";

const { mockPrisma } = vi.hoisted(() => ({
mockPrisma: { $queryRaw: vi.fn() },
}));

vi.mock("@/lib/db/client", () => ({ prisma: mockPrisma }));

import { adMediaFor } from "../lib/polling/comment-reconciler";

const POST = "18023946917554990";
const AD = "17899788633163100";

describe("adMediaFor", () => {
beforeEach(() => {
mockPrisma.$queryRaw.mockReset();
});

it("returns the ad media ids seen for the post", async () => {
mockPrisma.$queryRaw.mockResolvedValue([{ mediaId: AD }]);
await expect(adMediaFor(POST)).resolves.toEqual([AD]);
});

it("never returns the post itself, so it is not swept twice", async () => {
mockPrisma.$queryRaw.mockResolvedValue([{ mediaId: AD }, { mediaId: POST }]);
await expect(adMediaFor(POST)).resolves.toEqual([AD]);
});

it("drops rows without a media id", async () => {
mockPrisma.$queryRaw.mockResolvedValue([{ mediaId: null }, { mediaId: AD }]);
await expect(adMediaFor(POST)).resolves.toEqual([AD]);
});

it("returns nothing when the post was never boosted", async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
await expect(adMediaFor(POST)).resolves.toEqual([]);
});

it("swallows a query failure, leaving the post itself still swept", async () => {
mockPrisma.$queryRaw.mockRejectedValue(new Error("connection lost"));
await expect(adMediaFor(POST)).resolves.toEqual([]);
});
});
44 changes: 44 additions & 0 deletions lib/polling/comment-reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ async function sweepCampaign(
const mediaIds: string[] = [];
if (automation.postId) {
mediaIds.push(automation.postId);
mediaIds.push(...(await adMediaFor(automation.postId)));
} else if (automation.matchAnyPost) {
try {
const media = await getUserMedia(accessToken, RECENT_MEDIA_LIMIT);
Expand Down Expand Up @@ -237,6 +238,14 @@ async function sweepCampaign(
commenterId: c.from!.id,
commenterName: c.from?.username,
mediaId,
// When the sweep is looking at an ad, the campaign is bound to the post
// the ad was made from: without this the worker matches nothing and
// drops the comment, so the sweep would enqueue it again every five
// minutes and never deliver it.
originalMediaId:
automation.postId && mediaId !== automation.postId
? automation.postId
: undefined,
source: "POLLING",
});
stat.enqueued += 1;
Expand All @@ -246,6 +255,41 @@ async function sweepCampaign(
return stat;
}

/**
* Ad copies of a post, as seen in webhooks already received.
*
* Boosting a post gives it a second media id: comments left on the ad arrive
* with the ad's `media.id` and the post's id in `original_media_id`. The sweep
* would otherwise only ever look at the post itself, so a comment Meta fails to
* deliver on the ad is lost for good β€” exactly the case this safety net exists
* for, and the one where volume is highest.
*
* The ad ids are recovered from the webhooks themselves rather than from the
* ads API, which would need ads_management on top of the permissions the app
* already asks for. The trade-off: an ad becomes visible to the sweep only once
* a single comment on it has arrived. That is enough for the failure being
* covered here, where some webhooks arrive and others do not.
*/
export async function adMediaFor(postId: string): Promise<string[]> {
try {
const rows = await prisma.$queryRaw<{ mediaId: string | null }[]>`
SELECT DISTINCT change->'value'->'media'->>'id' AS "mediaId"
FROM "WebhookEvent" w,
jsonb_array_elements(w.payload::jsonb->'entry') entry,
jsonb_array_elements(entry->'changes') change
WHERE change->>'field' = 'comments'
AND change->'value'->'media'->>'original_media_id' = ${postId}
AND w."createdAt" > now() - interval '90 days'
`;
return rows
.map((r) => r.mediaId)
.filter((id): id is string => Boolean(id) && id !== postId);
} catch {
// A failure here must not stop the sweep: the post itself is still checked.
return [];
}
}

async function recordSweep(
workspaceId: string,
stat: SweepStat
Expand Down
Loading