diff --git a/lib/apify/__tests__/sendApifyWebhookEmail.test.ts b/lib/apify/__tests__/sendApifyWebhookEmail.test.ts
index a2044f63..f5ed448b 100644
--- a/lib/apify/__tests__/sendApifyWebhookEmail.test.ts
+++ b/lib/apify/__tests__/sendApifyWebhookEmail.test.ts
@@ -2,13 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { sendApifyWebhookEmail } from "@/lib/apify/sendApifyWebhookEmail";
import { RECOUP_FROM_EMAIL } from "@/lib/const";
-vi.mock("@/lib/ai/generateText", () => ({
- default: vi.fn(async () => ({ text: "
body
" })),
-}));
const sendEmailWithResend = vi.fn();
vi.mock("@/lib/emails/sendEmail", () => ({
sendEmailWithResend: (...a: unknown[]) => sendEmailWithResend(...a),
}));
+vi.mock("@/lib/composio/getFrontendBaseUrl", () => ({
+ getFrontendBaseUrl: () => "https://chat.recoupable.dev",
+}));
const PROFILE = {
fullName: "Ashnikko",
@@ -18,8 +18,16 @@ const PROFILE = {
biography: "bio",
followersCount: 100,
followsCount: 10,
- latestPosts: [],
+ latestPosts: [
+ {
+ url: "https://instagram.com/p/new1",
+ caption: "New tour dates",
+ displayUrl: "https://cdn.ig/new1.jpg",
+ timestamp: "2026-07-08T12:00:00.000Z",
+ },
+ ],
} as never;
+const NEW_URLS = ["https://instagram.com/p/new1"];
beforeEach(() => {
vi.clearAllMocks();
@@ -28,12 +36,8 @@ beforeEach(() => {
describe("sendApifyWebhookEmail", () => {
it("BCCs recipients so no account can see another's address (chat#1855)", async () => {
- const recipients = [
- "manager@customer-a.com",
- "label@customer-b.com",
- "internal@recoupable.com",
- ];
- await sendApifyWebhookEmail(PROFILE, recipients);
+ const recipients = ["manager@customer-a.com", "label@customer-b.com"];
+ await sendApifyWebhookEmail(PROFILE, recipients, NEW_URLS);
const payload = sendEmailWithResend.mock.calls[0][0] as Record;
expect(payload.bcc).toEqual(recipients);
expect(payload.to).toEqual([RECOUP_FROM_EMAIL]);
@@ -41,14 +45,32 @@ describe("sendApifyWebhookEmail", () => {
it("never carries more than our own address in to/cc, regardless of recipient count", async () => {
const recipients = Array.from({ length: 25 }, (_, i) => `user${i}@example.com`);
- await sendApifyWebhookEmail(PROFILE, recipients);
+ await sendApifyWebhookEmail(PROFILE, recipients, NEW_URLS);
const payload = sendEmailWithResend.mock.calls[0][0] as Record;
- const visible = [payload.to, payload.cc].flat().filter(Boolean);
- expect(visible).toEqual([RECOUP_FROM_EMAIL]);
+ expect([payload.to, payload.cc].flat().filter(Boolean)).toEqual([RECOUP_FROM_EMAIL]);
+ });
+
+ it("links the new posts in the deterministic body — no vendor jargon", async () => {
+ await sendApifyWebhookEmail(PROFILE, ["a@b.com"], NEW_URLS);
+ const payload = sendEmailWithResend.mock.calls[0][0] as Record;
+ expect(payload.html).toContain('href="https://instagram.com/p/new1"');
+ expect((payload.html + payload.subject).toLowerCase()).not.toContain("apify");
+ });
+
+ it("enriches the body with the post's media and caption from the dataset in hand", async () => {
+ await sendApifyWebhookEmail(PROFILE, ["a@b.com"], NEW_URLS);
+ const payload = sendEmailWithResend.mock.calls[0][0] as Record;
+ expect(payload.html).toContain('src="https://cdn.ig/new1.jpg"');
+ expect(payload.html).toContain("New tour dates");
});
it("returns null and sends nothing when there are no recipients", async () => {
- expect(await sendApifyWebhookEmail(PROFILE, [])).toBeNull();
+ expect(await sendApifyWebhookEmail(PROFILE, [], NEW_URLS)).toBeNull();
+ expect(sendEmailWithResend).not.toHaveBeenCalled();
+ });
+
+ it("returns null and sends nothing when no post is genuinely new", async () => {
+ expect(await sendApifyWebhookEmail(PROFILE, ["a@b.com"], [])).toBeNull();
expect(sendEmailWithResend).not.toHaveBeenCalled();
});
});
diff --git a/lib/apify/digest/__tests__/extractArtistNameFromDatasetItems.test.ts b/lib/apify/digest/__tests__/extractArtistNameFromDatasetItems.test.ts
new file mode 100644
index 00000000..ab3d1881
--- /dev/null
+++ b/lib/apify/digest/__tests__/extractArtistNameFromDatasetItems.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from "vitest";
+import { extractArtistNameFromDatasetItems } from "@/lib/apify/digest/extractArtistNameFromDatasetItems";
+
+describe("extractArtistNameFromDatasetItems", () => {
+ it("uses the Instagram profile fullName, falling back to username", () => {
+ expect(
+ extractArtistNameFromDatasetItems("instagram", [{ fullName: "National Geographic" }]),
+ ).toBe("National Geographic");
+ expect(extractArtistNameFromDatasetItems("instagram", [{ username: "natgeo" }])).toBe("natgeo");
+ });
+
+ it("uses the TikTok author nickName, falling back to handle", () => {
+ expect(
+ extractArtistNameFromDatasetItems("tiktok", [
+ { authorMeta: { name: "natgeo", nickName: "National Geographic" } },
+ ]),
+ ).toBe("National Geographic");
+ expect(extractArtistNameFromDatasetItems("tiktok", [{ authorMeta: { name: "natgeo" } }])).toBe(
+ "natgeo",
+ );
+ });
+
+ it("returns null for unknown platforms or empty items", () => {
+ expect(extractArtistNameFromDatasetItems("youtube", [{ some: "shape" }])).toBeNull();
+ expect(extractArtistNameFromDatasetItems("instagram", [])).toBeNull();
+ });
+});
+
+describe("extractArtistNameFromDatasetItems — twitter", () => {
+ it("uses the tweet author's display name, falling back to handle", () => {
+ expect(
+ extractArtistNameFromDatasetItems("twitter", [
+ { author: { name: "Sweetman", userName: "sweetman_eth" } },
+ ]),
+ ).toBe("Sweetman");
+ expect(extractArtistNameFromDatasetItems("x", [{ author: { userName: "sweetman_eth" } }])).toBe(
+ "sweetman_eth",
+ );
+ });
+});
diff --git a/lib/apify/digest/__tests__/extractPostsFromDatasetItems.test.ts b/lib/apify/digest/__tests__/extractPostsFromDatasetItems.test.ts
new file mode 100644
index 00000000..60e44e9f
--- /dev/null
+++ b/lib/apify/digest/__tests__/extractPostsFromDatasetItems.test.ts
@@ -0,0 +1,127 @@
+import { describe, it, expect } from "vitest";
+import { extractPostsFromDatasetItems } from "@/lib/apify/digest/extractPostsFromDatasetItems";
+
+const IG_ITEMS = [
+ {
+ latestPosts: [
+ {
+ url: "https://instagram.com/p/new1",
+ caption: "cap one",
+ displayUrl: "https://cdn.ig/new1.jpg",
+ timestamp: "2026-07-08T12:00:00.000Z",
+ likesCount: 100,
+ commentsCount: 5,
+ videoViewCount: 9000,
+ },
+ {
+ url: "https://instagram.com/p/old",
+ caption: "old",
+ displayUrl: "https://cdn.ig/old.jpg",
+ timestamp: "2026-06-01T00:00:00.000Z",
+ },
+ ],
+ },
+];
+
+const TIKTOK_ITEMS = [
+ {
+ webVideoUrl: "https://tiktok.com/@a/video/1",
+ text: "tt caption",
+ createTimeISO: "2026-07-09T09:00:00.000Z",
+ videoMeta: { coverUrl: "https://cdn.tt/1.jpg" },
+ diggCount: 200,
+ commentCount: 10,
+ playCount: 50000,
+ shareCount: 7,
+ },
+ {
+ webVideoUrl: "https://tiktok.com/@a/video/2",
+ text: "known",
+ createTimeISO: "2026-01-01T00:00:00.000Z",
+ },
+];
+
+describe("extractPostsFromDatasetItems", () => {
+ it("maps Instagram latestPosts limited to the new URLs, with media", () => {
+ const posts = extractPostsFromDatasetItems("instagram", IG_ITEMS, [
+ "https://instagram.com/p/new1",
+ ]);
+ expect(posts).toEqual([
+ {
+ url: "https://instagram.com/p/new1",
+ caption: "cap one",
+ thumbnailUrl: "https://cdn.ig/new1.jpg",
+ timestamp: "2026-07-08T12:00:00.000Z",
+ stats: { likes: 100, comments: 5, views: 9000 },
+ },
+ ]);
+ });
+
+ it("maps TikTok items limited to the new URLs, with cover media", () => {
+ const posts = extractPostsFromDatasetItems("tiktok", TIKTOK_ITEMS, [
+ "https://tiktok.com/@a/video/1",
+ ]);
+ expect(posts).toEqual([
+ {
+ url: "https://tiktok.com/@a/video/1",
+ caption: "tt caption",
+ thumbnailUrl: "https://cdn.tt/1.jpg",
+ timestamp: "2026-07-09T09:00:00.000Z",
+ stats: { likes: 200, comments: 10, views: 50000, shares: 7 },
+ },
+ ]);
+ });
+
+ it("falls back to URL-only posts for platforms without an extractor", () => {
+ const posts = extractPostsFromDatasetItems(
+ "youtube",
+ [{ some: "shape" }],
+ ["https://youtube.com/watch?v=1"],
+ );
+ expect(posts).toEqual([{ url: "https://youtube.com/watch?v=1" }]);
+ });
+
+ it("falls back to URL-only for new URLs missing from the dataset items", () => {
+ const posts = extractPostsFromDatasetItems("instagram", IG_ITEMS, [
+ "https://instagram.com/p/new1",
+ "https://instagram.com/p/not-in-items",
+ ]);
+ expect(posts).toHaveLength(2);
+ expect(posts[1]).toEqual({ url: "https://instagram.com/p/not-in-items" });
+ });
+});
+
+const TWEET_ITEMS = [
+ {
+ url: "https://x.com/a/status/1",
+ fullText: "New single out on all platforms",
+ createdAt: "Wed Jul 08 19:43:50 +0000 2026",
+ likeCount: 5,
+ replyCount: 1,
+ retweetCount: 2,
+ viewCount: 748,
+ extendedEntities: { media: [{ media_url_https: "https://pbs.twimg.com/media/x.jpg" }] },
+ },
+];
+
+describe("extractPostsFromDatasetItems — twitter", () => {
+ it("maps tweets limited to the new URLs, with media and stats", () => {
+ const posts = extractPostsFromDatasetItems("twitter", TWEET_ITEMS, [
+ "https://x.com/a/status/1",
+ ]);
+ expect(posts).toEqual([
+ {
+ url: "https://x.com/a/status/1",
+ caption: "New single out on all platforms",
+ thumbnailUrl: "https://pbs.twimg.com/media/x.jpg",
+ timestamp: "2026-07-08T19:43:50.000Z",
+ stats: { likes: 5, comments: 1, views: 748, shares: 2 },
+ },
+ ]);
+ });
+
+ it("maps the x platform alias identically", () => {
+ const posts = extractPostsFromDatasetItems("x", TWEET_ITEMS, ["https://x.com/a/status/1"]);
+ expect(posts[0].caption).toBe("New single out on all platforms");
+ });
+});
diff --git a/lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts b/lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts
index 8b2dbaba..0608e53d 100644
--- a/lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts
+++ b/lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts
@@ -10,6 +10,18 @@ vi.mock("@/lib/supabase/apify_scraper_runs/selectApifyScraperRuns", () => ({
vi.mock("@/lib/apify/digest/getScrapeDigestRecipients", () => ({
getScrapeDigestRecipients: vi.fn(),
}));
+vi.mock("@/lib/apify/digest/getRunDigestSection", () => ({
+ // URL-only translation of a run row — enrichment is unit-tested separately
+ getRunDigestSection: vi.fn(async (run: { platform: string; new_post_urls: string[] | null }) =>
+ run.new_post_urls?.length
+ ? {
+ platform: run.platform,
+ posts: run.new_post_urls.map((url: string) => ({ url })),
+ artistName: run.platform === "instagram" ? "National Geographic" : null,
+ }
+ : null,
+ ),
+}));
vi.mock("@/lib/apify/digest/sendScrapeDigestEmail", () => ({
sendScrapeDigestEmail: vi.fn(),
}));
@@ -51,10 +63,15 @@ describe("maybeSendScrapeDigest", () => {
expect(sendScrapeDigestEmail).toHaveBeenCalledOnce();
const arg = vi.mocked(sendScrapeDigestEmail).mock.calls[0][0];
expect(arg.sections).toEqual([
- { platform: "instagram", postUrls: ["https://instagram.com/p/1"] },
- { platform: "tiktok", postUrls: ["https://tiktok.com/v/2"] },
+ {
+ platform: "instagram",
+ posts: [{ url: "https://instagram.com/p/1" }],
+ artistName: "National Geographic",
+ },
+ { platform: "tiktok", posts: [{ url: "https://tiktok.com/v/2" }], artistName: null },
]); // x omitted — nothing new
expect(arg.emails).toEqual(["owner@example.com"]);
+ expect(arg.artistName).toBe("National Geographic"); // digest addressed by artist, not "Your artist"
});
it("sends nothing when the batch completes with zero new posts anywhere", async () => {
diff --git a/lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts b/lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts
new file mode 100644
index 00000000..de1083d3
--- /dev/null
+++ b/lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts
@@ -0,0 +1,115 @@
+import { describe, it, expect } from "vitest";
+import { renderScrapeDigestHtml } from "@/lib/apify/digest/renderScrapeDigestHtml";
+
+const SECTIONS = [
+ {
+ platform: "instagram",
+ posts: [
+ {
+ url: "https://instagram.com/p/abc",
+ caption: "Behind the scenes tour & more",
+ thumbnailUrl: "https://cdn.example.com/thumb-abc.jpg",
+ timestamp: "2026-07-08T12:00:00.000Z",
+ stats: { likes: 12345, comments: 678, views: 1200000 },
+ },
+ { url: "https://instagram.com/p/def", caption: null, thumbnailUrl: null, timestamp: null },
+ ],
+ },
+ {
+ platform: "tiktok",
+ posts: [
+ {
+ url: "https://tiktok.com/@a/video/1",
+ caption: "New single out now",
+ thumbnailUrl: "https://cdn.example.com/cover-1.jpg",
+ timestamp: "2026-07-09T09:30:00.000Z",
+ },
+ ],
+ },
+];
+
+describe("renderScrapeDigestHtml", () => {
+ it("links every new post, grouped under its platform label", () => {
+ const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" });
+ for (const s of SECTIONS) for (const p of s.posts) expect(html).toContain(`href="${p.url}"`);
+ expect(html).toContain("Instagram");
+ expect(html).toContain("TikTok");
+ });
+
+ it("labels every wired platform, including LinkedIn", () => {
+ const { html } = renderScrapeDigestHtml({
+ sections: [{ platform: "linkedin", posts: [{ url: "https://linkedin.com/posts/x" }] }],
+ artistName: "A",
+ });
+ expect(html).toContain("LinkedIn");
+ });
+
+ it("renders post media and caption excerpts when available", () => {
+ const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" });
+ expect(html).toContain('src="https://cdn.example.com/thumb-abc.jpg"');
+ expect(html).toContain('src="https://cdn.example.com/cover-1.jpg"');
+ expect(html).toContain("New single out now");
+ });
+
+ it("escapes HTML in captions so scraped content cannot inject markup", () => {
+ const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" });
+ expect(html).not.toContain("tour");
+ expect(html).toContain("<b>tour</b> & more");
+ });
+
+ it("renders compact engagement stats when available", () => {
+ const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" });
+ expect(html).toContain("12.3K likes");
+ expect(html).toContain("678 comments");
+ expect(html).toContain("1.2M views");
+ });
+
+ it("still renders a linked card when a post has no media or caption", () => {
+ const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" });
+ expect(html).toContain('href="https://instagram.com/p/def"');
+ });
+
+ it("is deterministic — identical input renders identical output", () => {
+ const a = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" });
+ const b = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" });
+ expect(a).toEqual(b);
+ });
+
+ it("uses the house style — achromatic chrome, no ad-hoc colors", () => {
+ const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" });
+ expect(html).toContain("#0a0a0a");
+ expect(html).toContain("#e8e8e8");
+ });
+
+ it("CTA always points at the chat app, never the API deployment", () => {
+ const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" });
+ expect(html).toContain('href="https://chat.recoupable.dev/?q=');
+ });
+
+ it("shows the Recoup logo linking to recoupable.com", () => {
+ const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" });
+ expect(html).toContain('href="https://recoupable.com"');
+ expect(html).toContain('src="https://chat.recoupable.dev/icon-with-background.png"');
+ });
+
+ it("names the artist in the roster footer, with a safe fallback", () => {
+ const named = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" });
+ expect(named.html).toContain("because Ashnikko is in your roster on Recoup");
+ const anon = renderScrapeDigestHtml({ sections: SECTIONS });
+ expect(anon.html).toContain("because this artist is in your roster on Recoup");
+ });
+
+ it("never leaks internal vendor jargon to customers", () => {
+ const { html, subject } = renderScrapeDigestHtml({
+ sections: SECTIONS,
+ artistName: "Ashnikko",
+ });
+ expect((html + subject).toLowerCase()).not.toContain("apify");
+ expect((html + subject).toLowerCase()).not.toContain("dataset");
+ });
+
+ it("counts posts and platforms in the subject", () => {
+ const { subject } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" });
+ expect(subject).toBe("Ashnikko: 3 new posts across 2 platforms");
+ });
+});
diff --git a/lib/apify/digest/__tests__/sendScrapeDigestEmail.test.ts b/lib/apify/digest/__tests__/sendScrapeDigestEmail.test.ts
new file mode 100644
index 00000000..2eed9b8e
--- /dev/null
+++ b/lib/apify/digest/__tests__/sendScrapeDigestEmail.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { sendScrapeDigestEmail } from "@/lib/apify/digest/sendScrapeDigestEmail";
+import { renderScrapeDigestHtml } from "@/lib/apify/digest/renderScrapeDigestHtml";
+import { RECOUP_FROM_EMAIL } from "@/lib/const";
+
+const sendEmailWithResend = vi.fn();
+vi.mock("@/lib/emails/sendEmail", () => ({
+ sendEmailWithResend: (...a: unknown[]) => sendEmailWithResend(...a),
+}));
+vi.mock("@/lib/composio/getFrontendBaseUrl", () => ({
+ getFrontendBaseUrl: () => "https://chat.recoupable.dev",
+}));
+
+const SECTIONS = [
+ {
+ platform: "instagram",
+ posts: [{ url: "https://instagram.com/p/a" }, { url: "https://instagram.com/p/b" }],
+ },
+ { platform: "tiktok", posts: [{ url: "https://tiktok.com/@x/video/1" }] },
+];
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ sendEmailWithResend.mockResolvedValue({ id: "email-1" });
+});
+
+describe("sendScrapeDigestEmail", () => {
+ it("sends the deterministic renderer's output, not an ad-hoc body (chat#1855)", async () => {
+ await sendScrapeDigestEmail({ emails: ["a@b.com"], sections: SECTIONS, artistName: "Nat" });
+ const payload = sendEmailWithResend.mock.calls[0][0] as Record;
+ const rendered = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Nat" });
+ expect(payload.subject).toBe(rendered.subject);
+ expect(payload.html).toBe(rendered.html);
+ });
+
+ it("BCCs recipients so no account can see another's address", async () => {
+ const recipients = ["a@b.com", "c@d.com"];
+ await sendScrapeDigestEmail({ emails: recipients, sections: SECTIONS });
+ const payload = sendEmailWithResend.mock.calls[0][0] as Record;
+ expect(payload.bcc).toEqual(recipients);
+ expect(payload.to).toEqual([RECOUP_FROM_EMAIL]);
+ });
+
+ it("sends nothing without recipients or sections", async () => {
+ expect(await sendScrapeDigestEmail({ emails: [], sections: SECTIONS })).toBeNull();
+ expect(await sendScrapeDigestEmail({ emails: ["a@b.com"], sections: [] })).toBeNull();
+ expect(sendEmailWithResend).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/apify/digest/asNumberOrNull.ts b/lib/apify/digest/asNumberOrNull.ts
new file mode 100644
index 00000000..6b82b5b5
--- /dev/null
+++ b/lib/apify/digest/asNumberOrNull.ts
@@ -0,0 +1,4 @@
+/** Coerces an unknown dataset value to a finite number, or null. */
+export function asNumberOrNull(v: unknown): number | null {
+ return typeof v === "number" && isFinite(v) ? v : null;
+}
diff --git a/lib/apify/digest/asRecord.ts b/lib/apify/digest/asRecord.ts
new file mode 100644
index 00000000..25d91f8f
--- /dev/null
+++ b/lib/apify/digest/asRecord.ts
@@ -0,0 +1,4 @@
+/** Coerces an unknown dataset value to a record, or an empty one. */
+export function asRecord(v: unknown): Record {
+ return v && typeof v === "object" ? (v as Record) : {};
+}
diff --git a/lib/apify/digest/asStringOrNull.ts b/lib/apify/digest/asStringOrNull.ts
new file mode 100644
index 00000000..34e2b955
--- /dev/null
+++ b/lib/apify/digest/asStringOrNull.ts
@@ -0,0 +1,4 @@
+/** Coerces an unknown dataset value to a non-empty string, or null. */
+export function asStringOrNull(v: unknown): string | null {
+ return typeof v === "string" && v ? v : null;
+}
diff --git a/lib/apify/digest/buildPostStats.ts b/lib/apify/digest/buildPostStats.ts
new file mode 100644
index 00000000..48ca14c8
--- /dev/null
+++ b/lib/apify/digest/buildPostStats.ts
@@ -0,0 +1,8 @@
+import type { ScrapeDigestPostStats } from "@/lib/apify/digest/renderScrapeDigestHtml";
+
+/** Prunes null counts; returns undefined when the scraper reported none. */
+export function buildPostStats(stats: ScrapeDigestPostStats): ScrapeDigestPostStats | undefined {
+ return Object.values(stats).some(v => v != null)
+ ? Object.fromEntries(Object.entries(stats).filter(([, v]) => v != null))
+ : undefined;
+}
diff --git a/lib/apify/digest/extractArtistNameFromDatasetItems.ts b/lib/apify/digest/extractArtistNameFromDatasetItems.ts
new file mode 100644
index 00000000..1d3eddf5
--- /dev/null
+++ b/lib/apify/digest/extractArtistNameFromDatasetItems.ts
@@ -0,0 +1,31 @@
+const asRecord = (v: unknown): Record =>
+ v && typeof v === "object" ? (v as Record) : {};
+
+const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
+
+/**
+ * Pulls the scraped profile's display name out of a platform's raw dataset
+ * items, so the digest can be addressed by artist instead of "Your artist"
+ * (chat#1855). Returns null for platforms without a known shape.
+ */
+export function extractArtistNameFromDatasetItems(
+ platform: string,
+ items: unknown[],
+): string | null {
+ const first = asRecord(items[0]);
+ switch (platform.toLowerCase()) {
+ case "instagram":
+ return str(first.fullName) ?? str(first.username);
+ case "tiktok": {
+ const author = asRecord(first.authorMeta);
+ return str(author.nickName) ?? str(author.name);
+ }
+ case "twitter":
+ case "x": {
+ const author = asRecord(first.author);
+ return str(author.name) ?? str(author.userName);
+ }
+ default:
+ return null;
+ }
+}
diff --git a/lib/apify/digest/extractInstagramPosts.ts b/lib/apify/digest/extractInstagramPosts.ts
new file mode 100644
index 00000000..7ae7fca0
--- /dev/null
+++ b/lib/apify/digest/extractInstagramPosts.ts
@@ -0,0 +1,29 @@
+import { asRecord } from "@/lib/apify/digest/asRecord";
+import { asStringOrNull } from "@/lib/apify/digest/asStringOrNull";
+import { asNumberOrNull } from "@/lib/apify/digest/asNumberOrNull";
+import { buildPostStats } from "@/lib/apify/digest/buildPostStats";
+import type { ScrapeDigestPost } from "@/lib/apify/digest/renderScrapeDigestHtml";
+
+/** Instagram profile-scraper dataset: posts ride on item[0].latestPosts. */
+export function extractInstagramPosts(items: unknown[]): Map {
+ const map = new Map();
+ const latest = asRecord(items[0]).latestPosts;
+ for (const raw of Array.isArray(latest) ? latest : []) {
+ const post = asRecord(raw);
+ const url = asStringOrNull(post.url);
+ if (!url) continue;
+ const stats = buildPostStats({
+ likes: asNumberOrNull(post.likesCount),
+ comments: asNumberOrNull(post.commentsCount),
+ views: asNumberOrNull(post.videoViewCount),
+ });
+ map.set(url, {
+ url,
+ caption: asStringOrNull(post.caption),
+ thumbnailUrl: asStringOrNull(post.displayUrl),
+ timestamp: asStringOrNull(post.timestamp),
+ ...(stats && { stats }),
+ });
+ }
+ return map;
+}
diff --git a/lib/apify/digest/extractPostsFromDatasetItems.ts b/lib/apify/digest/extractPostsFromDatasetItems.ts
new file mode 100644
index 00000000..2ed8793d
--- /dev/null
+++ b/lib/apify/digest/extractPostsFromDatasetItems.ts
@@ -0,0 +1,27 @@
+import { extractInstagramPosts } from "@/lib/apify/digest/extractInstagramPosts";
+import { extractTiktokPosts } from "@/lib/apify/digest/extractTiktokPosts";
+import { extractTwitterPosts } from "@/lib/apify/digest/extractTwitterPosts";
+import type { ScrapeDigestPost } from "@/lib/apify/digest/renderScrapeDigestHtml";
+
+const EXTRACTORS: Record Map> = {
+ instagram: extractInstagramPosts,
+ tiktok: extractTiktokPosts,
+ twitter: extractTwitterPosts,
+ x: extractTwitterPosts,
+};
+
+/**
+ * Builds rich digest posts (caption, media, stats, timestamp) for a
+ * platform's genuinely-new post URLs from its raw scraper dataset items.
+ * Platforms without an extractor — and URLs missing from the items —
+ * degrade to URL-only posts, so enrichment can never lose a link (chat#1855).
+ */
+export function extractPostsFromDatasetItems(
+ platform: string,
+ items: unknown[],
+ newPostUrls: string[],
+): ScrapeDigestPost[] {
+ const extractor = EXTRACTORS[platform.toLowerCase()];
+ const byUrl = extractor ? extractor(items) : new Map();
+ return newPostUrls.map(url => byUrl.get(url) ?? { url });
+}
diff --git a/lib/apify/digest/extractTiktokPosts.ts b/lib/apify/digest/extractTiktokPosts.ts
new file mode 100644
index 00000000..a3346b22
--- /dev/null
+++ b/lib/apify/digest/extractTiktokPosts.ts
@@ -0,0 +1,29 @@
+import { asRecord } from "@/lib/apify/digest/asRecord";
+import { asStringOrNull } from "@/lib/apify/digest/asStringOrNull";
+import { asNumberOrNull } from "@/lib/apify/digest/asNumberOrNull";
+import { buildPostStats } from "@/lib/apify/digest/buildPostStats";
+import type { ScrapeDigestPost } from "@/lib/apify/digest/renderScrapeDigestHtml";
+
+/** TikTok scraper dataset: one item per video. */
+export function extractTiktokPosts(items: unknown[]): Map {
+ const map = new Map();
+ for (const raw of items) {
+ const item = asRecord(raw);
+ const url = asStringOrNull(item.webVideoUrl);
+ if (!url) continue;
+ const stats = buildPostStats({
+ likes: asNumberOrNull(item.diggCount),
+ comments: asNumberOrNull(item.commentCount),
+ views: asNumberOrNull(item.playCount),
+ shares: asNumberOrNull(item.shareCount),
+ });
+ map.set(url, {
+ url,
+ caption: asStringOrNull(item.text),
+ thumbnailUrl: asStringOrNull(asRecord(item.videoMeta).coverUrl),
+ timestamp: asStringOrNull(item.createTimeISO),
+ ...(stats && { stats }),
+ });
+ }
+ return map;
+}
diff --git a/lib/apify/digest/extractTwitterPosts.ts b/lib/apify/digest/extractTwitterPosts.ts
new file mode 100644
index 00000000..e9320f34
--- /dev/null
+++ b/lib/apify/digest/extractTwitterPosts.ts
@@ -0,0 +1,32 @@
+import { asRecord } from "@/lib/apify/digest/asRecord";
+import { asStringOrNull } from "@/lib/apify/digest/asStringOrNull";
+import { asNumberOrNull } from "@/lib/apify/digest/asNumberOrNull";
+import { buildPostStats } from "@/lib/apify/digest/buildPostStats";
+import { toIsoDate } from "@/lib/apify/toIsoDate";
+import type { ScrapeDigestPost } from "@/lib/apify/digest/renderScrapeDigestHtml";
+
+/** X/Twitter scraper dataset (apidojo): one item per tweet. */
+export function extractTwitterPosts(items: unknown[]): Map {
+ const map = new Map();
+ for (const raw of items) {
+ const item = asRecord(raw);
+ const url = asStringOrNull(item.url);
+ if (!url) continue;
+ const media = asRecord(item.extendedEntities).media;
+ const firstMedia = asRecord(Array.isArray(media) ? media[0] : undefined);
+ const stats = buildPostStats({
+ likes: asNumberOrNull(item.likeCount),
+ comments: asNumberOrNull(item.replyCount),
+ views: asNumberOrNull(item.viewCount),
+ shares: asNumberOrNull(item.retweetCount),
+ });
+ map.set(url, {
+ url,
+ caption: asStringOrNull(item.fullText) ?? asStringOrNull(item.text),
+ thumbnailUrl: asStringOrNull(firstMedia.media_url_https),
+ timestamp: toIsoDate(asStringOrNull(item.createdAt) ?? undefined) ?? null,
+ ...(stats && { stats }),
+ });
+ }
+ return map;
+}
diff --git a/lib/apify/digest/formatCompactCount.ts b/lib/apify/digest/formatCompactCount.ts
new file mode 100644
index 00000000..4194fba3
--- /dev/null
+++ b/lib/apify/digest/formatCompactCount.ts
@@ -0,0 +1,6 @@
+/** Deterministic compact count: 678, 12.3K, 1.2M (trailing .0 stripped). */
+export function formatCompactCount(n: number): string {
+ if (n < 1000) return String(n);
+ const [value, unit] = n < 1_000_000 ? [n / 1000, "K"] : [n / 1_000_000, "M"];
+ return `${value.toFixed(1).replace(/\.0$/, "")}${unit}`;
+}
diff --git a/lib/apify/digest/formatPostStats.ts b/lib/apify/digest/formatPostStats.ts
new file mode 100644
index 00000000..09e5a1f2
--- /dev/null
+++ b/lib/apify/digest/formatPostStats.ts
@@ -0,0 +1,12 @@
+import { formatCompactCount } from "@/lib/apify/digest/formatCompactCount";
+import type { ScrapeDigestPostStats } from "@/lib/apify/digest/renderScrapeDigestHtml";
+
+/** "12.3K likes · 678 comments · 1.2M views" from whichever stats exist. */
+export function formatPostStats(stats: ScrapeDigestPostStats): string {
+ const parts: string[] = [];
+ if (stats.likes != null) parts.push(`${formatCompactCount(stats.likes)} likes`);
+ if (stats.comments != null) parts.push(`${formatCompactCount(stats.comments)} comments`);
+ if (stats.views != null) parts.push(`${formatCompactCount(stats.views)} views`);
+ if (stats.shares != null) parts.push(`${formatCompactCount(stats.shares)} shares`);
+ return parts.join(" · ");
+}
diff --git a/lib/apify/digest/formatUtcDateLabel.ts b/lib/apify/digest/formatUtcDateLabel.ts
new file mode 100644
index 00000000..14f7100a
--- /dev/null
+++ b/lib/apify/digest/formatUtcDateLabel.ts
@@ -0,0 +1,8 @@
+const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+
+/** Deterministic "Jul 8" date label (UTC — no locale/timezone variance). */
+export function formatUtcDateLabel(iso: string): string {
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return "";
+ return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
+}
diff --git a/lib/apify/digest/getPlatformLabel.ts b/lib/apify/digest/getPlatformLabel.ts
new file mode 100644
index 00000000..48f0a51f
--- /dev/null
+++ b/lib/apify/digest/getPlatformLabel.ts
@@ -0,0 +1,15 @@
+const PLATFORM_LABELS: Record = {
+ instagram: "Instagram",
+ tiktok: "TikTok",
+ x: "X",
+ twitter: "X",
+ youtube: "YouTube",
+ linkedin: "LinkedIn",
+ facebook: "Facebook",
+ threads: "Threads",
+};
+
+/** Customer-facing label for a scraper platform key. */
+export function getPlatformLabel(platform: string): string {
+ return PLATFORM_LABELS[platform.toLowerCase()] ?? platform;
+}
diff --git a/lib/apify/digest/getRunDigestSection.ts b/lib/apify/digest/getRunDigestSection.ts
new file mode 100644
index 00000000..96b103bc
--- /dev/null
+++ b/lib/apify/digest/getRunDigestSection.ts
@@ -0,0 +1,40 @@
+import apifyClient from "@/lib/apify/client";
+import { parseNewPostUrls } from "@/lib/apify/digest/parseNewPostUrls";
+import { extractPostsFromDatasetItems } from "@/lib/apify/digest/extractPostsFromDatasetItems";
+import { extractArtistNameFromDatasetItems } from "@/lib/apify/digest/extractArtistNameFromDatasetItems";
+import type { ScrapeDigestSection } from "@/lib/apify/digest/renderScrapeDigestHtml";
+import type { Tables } from "@/types/database.types";
+
+/** A digest section plus the profile display name the platform reported. */
+export type RunDigestSection = ScrapeDigestSection & { artistName: string | null };
+
+/**
+ * Builds one platform's digest section for a completed scrape run, enriching
+ * the stored genuinely-new URLs with caption/media/stats and the profile's
+ * display name by re-reading the run's dataset from Apify (the source of
+ * truth for scrape content — only the URL diff is persisted, chat#1855).
+ * Enrichment must never block the digest: any failure degrades to URL-only
+ * posts. Returns null when the run found nothing new.
+ */
+export async function getRunDigestSection(
+ run: Tables<"apify_scraper_runs">,
+): Promise {
+ const urls = parseNewPostUrls(run.new_post_urls);
+ if (!urls.length) return null;
+ const platform = run.platform ?? "other";
+
+ try {
+ const runInfo = await apifyClient.run(run.run_id).get();
+ const datasetId = runInfo?.defaultDatasetId;
+ if (!datasetId) return { platform, posts: urls.map(url => ({ url })), artistName: null };
+ const { items } = await apifyClient.dataset(datasetId).listItems();
+ return {
+ platform,
+ posts: extractPostsFromDatasetItems(platform, items, urls),
+ artistName: extractArtistNameFromDatasetItems(platform, items),
+ };
+ } catch (error) {
+ console.error("[WARN] digest enrichment failed; sending URL-only links:", error);
+ return { platform, posts: urls.map(url => ({ url })), artistName: null };
+ }
+}
diff --git a/lib/apify/digest/maybeSendScrapeDigest.ts b/lib/apify/digest/maybeSendScrapeDigest.ts
index cd14c13f..c68b5179 100644
--- a/lib/apify/digest/maybeSendScrapeDigest.ts
+++ b/lib/apify/digest/maybeSendScrapeDigest.ts
@@ -1,5 +1,6 @@
import { selectApifyScraperRuns } from "@/lib/supabase/apify_scraper_runs/selectApifyScraperRuns";
-import { parseNewPostUrls } from "@/lib/apify/digest/parseNewPostUrls";
+import { getRunDigestSection } from "@/lib/apify/digest/getRunDigestSection";
+import type { RunDigestSection } from "@/lib/apify/digest/getRunDigestSection";
import { getScrapeDigestRecipients } from "@/lib/apify/digest/getScrapeDigestRecipients";
import { sendScrapeDigestEmail } from "@/lib/apify/digest/sendScrapeDigestEmail";
@@ -16,13 +17,15 @@ export async function maybeSendScrapeDigest(batchId: string | null | undefined)
const runs = await selectApifyScraperRuns({ batchId });
if (!runs.length || runs.some(r => !r.completed_at)) return null;
- const sections = runs
- .map(r => ({ platform: r.platform ?? "other", postUrls: parseNewPostUrls(r.new_post_urls) }))
- .filter(s => s.postUrls.length > 0);
+ const sections = (await Promise.all(runs.map(getRunDigestSection))).filter(
+ (s): s is RunDigestSection => s !== null,
+ );
if (!sections.length) return null;
const emails = await getScrapeDigestRecipients(
runs.map(r => r.social_id).filter((id): id is string => Boolean(id)),
);
- return await sendScrapeDigestEmail({ emails, sections });
+ // A batch is one artist's scrape — any platform's profile name addresses it.
+ const artistName = sections.map(s => s.artistName).find(Boolean) ?? null;
+ return await sendScrapeDigestEmail({ emails, sections, artistName });
}
diff --git a/lib/apify/digest/renderScrapeDigestHtml.ts b/lib/apify/digest/renderScrapeDigestHtml.ts
new file mode 100644
index 00000000..0745b1f9
--- /dev/null
+++ b/lib/apify/digest/renderScrapeDigestHtml.ts
@@ -0,0 +1,84 @@
+import { CHAT_APP_URL, RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const";
+import { escapeHtml } from "@/lib/emails/escapeHtml";
+import { formatUtcDateLabel } from "@/lib/apify/digest/formatUtcDateLabel";
+import { formatPostStats } from "@/lib/apify/digest/formatPostStats";
+import { getPlatformLabel } from "@/lib/apify/digest/getPlatformLabel";
+import { truncateText } from "@/lib/apify/digest/truncateText";
+
+export type ScrapeDigestPostStats = {
+ likes?: number | null;
+ comments?: number | null;
+ views?: number | null;
+ shares?: number | null;
+};
+
+export type ScrapeDigestPost = {
+ url: string;
+ caption?: string | null;
+ thumbnailUrl?: string | null;
+ timestamp?: string | null;
+ stats?: ScrapeDigestPostStats | null;
+};
+
+export type ScrapeDigestSection = { platform: string; posts: ScrapeDigestPost[] };
+
+/**
+ * Deterministic house-style renderer for the new-posts digest (chat#1855).
+ * Replaces the per-send LLM body: identical input renders identical output,
+ * every genuinely-new post is a linked card with its media, caption, and
+ * engagement stats, and internal vendor jargon never reaches customers.
+ * Styling follows DESIGN.md — achromatic chrome (#0a0a0a on #ffffff, #e8e8e8
+ * borders, #6b6b6b muted); color comes from the post media, not the chrome.
+ * Email-safe: tables + inline styles only, system font stack. The CTA is the
+ * fixed CHAT_APP_URL — never a derived deployment URL (previews would point
+ * customers at the API deployment itself).
+ */
+export function renderScrapeDigestHtml({
+ sections,
+ artistName,
+}: {
+ sections: ScrapeDigestSection[];
+ artistName?: string | null;
+}): { subject: string; html: string } {
+ const who = artistName || "Your artist";
+ const total = sections.reduce((n, s) => n + s.posts.length, 0);
+ const subject = `${who}: ${total} new post${total === 1 ? "" : "s"} across ${sections.length} platform${sections.length === 1 ? "" : "s"}`;
+
+ const sectionHtml = sections
+ .map(section => {
+ const cards = section.posts
+ .map(post => {
+ const href = post.url.startsWith("http") ? post.url : `https://${post.url}`;
+ const caption = post.caption ? escapeHtml(truncateText(post.caption, 110)) : "";
+ const date = post.timestamp ? formatUtcDateLabel(post.timestamp) : "";
+ const stats = post.stats ? formatPostStats(post.stats) : "";
+ const thumbCell = post.thumbnailUrl
+ ? ` | `
+ : "";
+ return `${thumbCell}| ${caption ? ` ${caption} ` : ""}${stats ? `${stats} ` : ""}${date ? `${date} · ` : ""}View post → |
|
`;
+ })
+ .join("");
+ return `${getPlatformLabel(section.platform)}
${cards}`;
+ })
+ .join("");
+
+ const chatUrl = `${CHAT_APP_URL}/?q=${encodeURIComponent("tell me about my artist's latest posts")}`;
+ const html = `
+
+
+
+|
+ New posts
+${escapeHtml(who)}
+${total} new post${total === 1 ? "" : "s"} since we last checked
+ |
+ |
+
+${sectionHtml}
+
+You're receiving this because ${artistName ? escapeHtml(artistName) : "this artist"} is in your roster on Recoup.
+ |
+ |
`;
+
+ return { subject, html };
+}
diff --git a/lib/apify/digest/sendScrapeDigestEmail.ts b/lib/apify/digest/sendScrapeDigestEmail.ts
index c75df694..3d1cfcf2 100644
--- a/lib/apify/digest/sendScrapeDigestEmail.ts
+++ b/lib/apify/digest/sendScrapeDigestEmail.ts
@@ -1,7 +1,8 @@
import { sendEmailWithResend } from "@/lib/emails/sendEmail";
import { RECOUP_FROM_EMAIL } from "@/lib/const";
+import { renderScrapeDigestHtml } from "@/lib/apify/digest/renderScrapeDigestHtml";
+import type { ScrapeDigestSection } from "@/lib/apify/digest/renderScrapeDigestHtml";
-export type ScrapeDigestSection = { platform: string; postUrls: string[] };
export type ScrapeDigestInput = {
emails: string[];
sections: ScrapeDigestSection[];
@@ -17,22 +18,13 @@ export type ScrapeDigestInput = {
export async function sendScrapeDigestEmail({ emails, sections, artistName }: ScrapeDigestInput) {
if (!emails.length || !sections.length) return null;
- const total = sections.reduce((n, s) => n + s.postUrls.length, 0);
- const who = artistName || "your artist";
- const sectionHtml = sections
- .map(
- s =>
- `${s.platform}
${s.postUrls
- .map(u => `- ${u}
`)
- .join("")}
`,
- )
- .join("");
+ const { subject, html } = renderScrapeDigestHtml({ sections, artistName });
return await sendEmailWithResend({
from: RECOUP_FROM_EMAIL,
to: [RECOUP_FROM_EMAIL],
bcc: emails,
- subject: `${who}: ${total} new post${total === 1 ? "" : "s"} found across ${sections.length} platform${sections.length === 1 ? "" : "s"}`,
- html: `New posts found for ${who}:
${sectionHtml}`,
+ subject,
+ html,
});
}
diff --git a/lib/apify/digest/truncateText.ts b/lib/apify/digest/truncateText.ts
new file mode 100644
index 00000000..13c36986
--- /dev/null
+++ b/lib/apify/digest/truncateText.ts
@@ -0,0 +1,4 @@
+/** Truncates to `max` characters with a trailing ellipsis. */
+export function truncateText(text: string, max: number): string {
+ return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
+}
diff --git a/lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts b/lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts
index 2a985014..d8310dbd 100644
--- a/lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts
+++ b/lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts
@@ -97,7 +97,7 @@ describe("handleInstagramProfileScraperResults", () => {
expect(upsertPosts).toHaveBeenCalledOnce();
expect(upsertSocialPosts).toHaveBeenCalledOnce();
- expect(sendApifyWebhookEmail).toHaveBeenCalledWith(expect.any(Object), ["x@y.com"]);
+ expect(sendApifyWebhookEmail).toHaveBeenCalledWith(expect.any(Object), ["x@y.com"], ["u1"]);
expect(handleInstagramProfileFollowUpRuns).toHaveBeenCalledOnce();
expect(result.social).toEqual({ id: "s1" });
expect(result.posts).toEqual(posts);
diff --git a/lib/apify/instagram/handleInstagramProfileScraperResults.ts b/lib/apify/instagram/handleInstagramProfileScraperResults.ts
index 3244e8eb..de38d4c4 100644
--- a/lib/apify/instagram/handleInstagramProfileScraperResults.ts
+++ b/lib/apify/instagram/handleInstagramProfileScraperResults.ts
@@ -112,6 +112,7 @@ export async function handleInstagramProfileScraperResults(parsed: ApifyWebhookP
sentEmails = await sendApifyWebhookEmail(
firstResult,
accountEmails.map(e => e.email).filter(Boolean),
+ newPostUrls,
);
}
} catch (error) {
diff --git a/lib/apify/sendApifyWebhookEmail.ts b/lib/apify/sendApifyWebhookEmail.ts
index 3510dccd..1f07ce8d 100644
--- a/lib/apify/sendApifyWebhookEmail.ts
+++ b/lib/apify/sendApifyWebhookEmail.ts
@@ -1,48 +1,37 @@
-import generateText from "@/lib/ai/generateText";
import { sendEmailWithResend } from "@/lib/emails/sendEmail";
import { RECOUP_FROM_EMAIL } from "@/lib/const";
-import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl";
+import { renderScrapeDigestHtml } from "@/lib/apify/digest/renderScrapeDigestHtml";
+import { extractPostsFromDatasetItems } from "@/lib/apify/digest/extractPostsFromDatasetItems";
import type { ApifyInstagramProfileResult } from "@/lib/apify/types";
/**
- * Sends an Apify-webhook summary email to the given recipients using
- * Resend. Generates the email body via an LLM based on the first
- * profile result.
+ * Sends the legacy single-platform Instagram alert (non-batch scrape runs).
+ * Batch runs get the consolidated digest instead. Body comes from the shared
+ * deterministic renderer — the per-send LLM body is gone (chat#1855): stable
+ * branding, direct links to the new posts, no vendor jargon.
*
* @param profile - First dataset result from the profile scraper.
- * @param emails - Recipient email addresses.
- * @returns The Resend response, or `null` when there are no recipients.
+ * @param emails - Recipient email addresses (cross-tenant — BCC only).
+ * @param newPostUrls - Post URLs genuinely new to the platform.
+ * @returns The Resend response, or `null` when there is nothing to send.
*/
export async function sendApifyWebhookEmail(
profile: ApifyInstagramProfileResult,
emails: string[],
+ newPostUrls: string[] = [],
) {
- if (!emails?.length) return null;
+ if (!emails?.length || !newPostUrls.length) return null;
- const prompt = `You have a new Apify dataset update. Here is the data:
-
-Key Data
-Full Name: ${profile.fullName}
-Username: ${profile.username}
-Profile URL: ${profile.url}
-Profile Picture: ${profile.profilePicUrl}
-Biography: ${profile.biography}
-Followers: ${profile.followersCount}
-Following: ${profile.followsCount}
-Latest Posts: ${(Array.isArray(profile.latestPosts) ? profile.latestPosts : []).map(p => JSON.stringify(p)).join(", ")}
-`;
-
- const { text } = await generateText({
- system: `you are a record label services manager for Recoup.
- write beautiful html email.
- subject: New Apify Dataset Notification. you're notifying music managers about new posts being available for one of their roster musician's Instagram profile.
- include a link to view the instagram profile.
- call to action is to open a chat link to learn more about the latest posts using Recoup Chat (AI Agents): ${getFrontendBaseUrl()}/?q=tell%20me%20about%20my%20latest%20Ig%20posts
- You'll be passed a dataset summary for a musician profile and their latest posts on instagram.
- your goal is to get the recipient to click a cta link to open a chat link to learn more about the latest posts using Recoup Chat (AI Agents).
- only include the email body html.
- no headers or subject`,
- prompt,
+ // The profile IS dataset item 0, so the shared extractor enriches the new
+ // URLs with caption/media/timestamp from latestPosts already in hand.
+ const { subject, html } = renderScrapeDigestHtml({
+ sections: [
+ {
+ platform: "instagram",
+ posts: extractPostsFromDatasetItems("instagram", [profile], newPostUrls),
+ },
+ ],
+ artistName: profile.fullName ?? profile.username ?? null,
});
// Recipients span multiple customer accounts (the social's watchers are
@@ -52,7 +41,7 @@ Latest Posts: ${(Array.isArray(profile.latestPosts) ? profile.latestPosts : []).
from: RECOUP_FROM_EMAIL,
to: [RECOUP_FROM_EMAIL],
bcc: emails,
- subject: `${profile.fullName ?? profile.username ?? "Your artist"} has new posts on Instagram`,
- html: text,
+ subject,
+ html,
});
}
diff --git a/lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts b/lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts
index 0497e058..3d53e946 100644
--- a/lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts
+++ b/lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts
@@ -76,4 +76,22 @@ describe("handleTwitterProfileScraperResults", () => {
expect(upsertSocials).not.toHaveBeenCalled();
expect(persistPostsForSocial).not.toHaveBeenCalled();
});
+
+ it("excludes retweets and replies from persisted posts and the newness diff", async () => {
+ listItems.mockResolvedValue({
+ items: [
+ { ...REAL_ITEM, url: "https://x.com/a/status/1" },
+ { ...REAL_ITEM, url: "https://x.com/other/status/2", isRetweet: true },
+ { ...REAL_ITEM, url: "https://x.com/a/status/3", isReply: true },
+ ],
+ });
+ await handleTwitterProfileScraperResults(payload as never);
+ expect(persistPostsForSocial).toHaveBeenCalledWith(
+ expect.objectContaining({
+ postRows: [
+ { post_url: "https://x.com/a/status/1", updated_at: "2026-07-02T17:21:21.000Z" },
+ ],
+ }),
+ );
+ });
});
diff --git a/lib/apify/twitter/__tests__/isOriginalTweet.test.ts b/lib/apify/twitter/__tests__/isOriginalTweet.test.ts
new file mode 100644
index 00000000..6ac47cec
--- /dev/null
+++ b/lib/apify/twitter/__tests__/isOriginalTweet.test.ts
@@ -0,0 +1,20 @@
+import { describe, it, expect } from "vitest";
+import { isOriginalTweet } from "@/lib/apify/twitter/isOriginalTweet";
+
+describe("isOriginalTweet", () => {
+ it("keeps original tweets", () => {
+ expect(isOriginalTweet({ isRetweet: false, isQuote: false, isReply: false })).toBe(true);
+ });
+ it("keeps quote tweets — the artist adds their own words and earns the stats", () => {
+ expect(isOriginalTweet({ isRetweet: false, isQuote: true, isReply: false })).toBe(true);
+ });
+ it("drops retweets — the content and metrics belong to someone else", () => {
+ expect(isOriginalTweet({ isRetweet: true, isQuote: false, isReply: false })).toBe(false);
+ });
+ it("drops replies — conversational noise, not roster posts", () => {
+ expect(isOriginalTweet({ isRetweet: false, isQuote: false, isReply: true })).toBe(false);
+ });
+ it("keeps items without flags (defensive default)", () => {
+ expect(isOriginalTweet({})).toBe(true);
+ });
+});
diff --git a/lib/apify/twitter/__tests__/startTwitterProfileScraping.test.ts b/lib/apify/twitter/__tests__/startTwitterProfileScraping.test.ts
index 325e1f48..43fc1605 100644
--- a/lib/apify/twitter/__tests__/startTwitterProfileScraping.test.ts
+++ b/lib/apify/twitter/__tests__/startTwitterProfileScraping.test.ts
@@ -11,11 +11,11 @@ beforeEach(() => {
});
describe("startTwitterProfileScraping", () => {
- it("defaults to the legacy single-item snapshot (maxItems: 1) when posts is omitted", async () => {
+ it("defaults to a 10-item fetch when posts is omitted — retweets/replies are filtered AFTER the fetch, so depth 1 would rarely surface an authored post", async () => {
const r = await startTwitterProfileScraping("sweetman_eth");
expect(apifyClient.actor).toHaveBeenCalledWith("apidojo/twitter-scraper-lite");
expect(start).toHaveBeenCalledWith(
- { twitterHandles: ["sweetman_eth"], sort: "Latest", maxItems: 1 },
+ { twitterHandles: ["sweetman_eth"], sort: "Latest", maxItems: 10 },
{ webhooks: expect.any(Array) },
);
expect(r).toEqual({ runId: "run-1", datasetId: "ds-1" });
diff --git a/lib/apify/twitter/handleTwitterProfileScraperResults.ts b/lib/apify/twitter/handleTwitterProfileScraperResults.ts
index 4634fae0..766aa6e1 100644
--- a/lib/apify/twitter/handleTwitterProfileScraperResults.ts
+++ b/lib/apify/twitter/handleTwitterProfileScraperResults.ts
@@ -4,12 +4,16 @@ import { normalizeProfileUrl } from "@/lib/socials/normalizeProfileUrl";
import { persistPostsForSocial } from "@/lib/apify/persistPostsForSocial";
import { filterNewPostUrls } from "@/lib/socials/filterNewPostUrls";
import { toIsoDate } from "@/lib/apify/toIsoDate";
+import { isOriginalTweet } from "@/lib/apify/twitter/isOriginalTweet";
import type { ApifyWebhookPayload } from "@/lib/apify/validateApifyWebhookRequest";
import type { TablesInsert } from "@/types/database.types";
/** Tweet item from apidojo~twitter-scraper-lite (real shape, run ALVMZYXkh3WHgeGfT;
* tweet fields verified on run bx3asRqfbNnkKgogG). */
type TweetItem = {
+ isRetweet?: boolean;
+ isQuote?: boolean;
+ isReply?: boolean;
url?: string;
createdAt?: string;
author?: {
@@ -50,8 +54,12 @@ export async function handleTwitterProfileScraperResults(parsed: ApifyWebhookPay
// Tweet URLs keep the author's display casing — the path segment is the
// case-sensitive status id's context, and unlike profile keys they are
// stored as-is (posts upsert keys on exact post_url).
+ // Only the artist's own posts count — retweets are someone else's content
+ // and stats, replies are conversation (chat#1855).
const postRows: TablesInsert<"posts">[] = (items as TweetItem[]).flatMap(item =>
- item.url ? [{ post_url: item.url, updated_at: toIsoDate(item.createdAt) }] : [],
+ item.url && isOriginalTweet(item)
+ ? [{ post_url: item.url, updated_at: toIsoDate(item.createdAt) }]
+ : [],
);
// Diff before persisting so the digest can report genuinely new posts (chat#1855).
const newPostUrls = await filterNewPostUrls(postRows.map(p => p.post_url));
diff --git a/lib/apify/twitter/isOriginalTweet.ts b/lib/apify/twitter/isOriginalTweet.ts
new file mode 100644
index 00000000..22d435db
--- /dev/null
+++ b/lib/apify/twitter/isOriginalTweet.ts
@@ -0,0 +1,14 @@
+type TweetFlags = { isRetweet?: boolean; isQuote?: boolean; isReply?: boolean };
+
+/**
+ * True for tweets the artist actually authored — originals and quote tweets
+ * (their own words on top). Retweets are someone else's content and carry the
+ * ORIGINAL author's engagement stats, so persisting or reporting them
+ * misattributes both (chat#1855 feedback: an all-retweet digest section
+ * showed "hundreds of likes" the artist never earned). Replies are excluded
+ * as conversational noise rather than roster posts. Items without flags pass
+ * (defensive default — never silently drop a scrape result on schema drift).
+ */
+export function isOriginalTweet(item: TweetFlags): boolean {
+ return !item.isRetweet && !item.isReply;
+}
diff --git a/lib/apify/twitter/startTwitterProfileScraping.ts b/lib/apify/twitter/startTwitterProfileScraping.ts
index 16821355..5de2e1bf 100644
--- a/lib/apify/twitter/startTwitterProfileScraping.ts
+++ b/lib/apify/twitter/startTwitterProfileScraping.ts
@@ -15,7 +15,10 @@ const startTwitterProfileScraping = async (
const input = {
twitterHandles: [cleanHandle],
sort: "Latest",
- maxItems: posts ?? 1,
+ // Timeline items include retweets/replies, which are dropped AFTER the
+ // fetch (isOriginalTweet) — depth 1 would rarely surface an authored
+ // post for active retweeters (chat#1855).
+ maxItems: posts ?? 10,
};
const run = await apifyClient
diff --git a/lib/const.ts b/lib/const.ts
index 5b178dd0..4a05312c 100644
--- a/lib/const.ts
+++ b/lib/const.ts
@@ -15,6 +15,16 @@ export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET;
/** Base URL for the public API documentation site */
export const DOCS_BASE_URL = "https://docs.recoupable.dev";
+/** Public marketing site. */
+export const WEBSITE_URL = "https://recoupable.com";
+
+/** Chat app — customer-facing emails must always link here, never a derived
+ * deployment URL (previews would point at the API deployment itself). */
+export const CHAT_APP_URL = "https://chat.recoupable.dev";
+
+/** Brand icon hosted on prod chat (email clients need an absolute PNG). */
+export const RECOUP_LOGO_URL = "https://chat.recoupable.dev/icon-with-background.png";
+
/** Domain for receiving inbound emails (e.g., support@recoupable.dev) */
export const INBOUND_EMAIL_DOMAIN = "@recoupable.dev";
diff --git a/lib/emails/escapeHtml.ts b/lib/emails/escapeHtml.ts
new file mode 100644
index 00000000..c0d8bf6d
--- /dev/null
+++ b/lib/emails/escapeHtml.ts
@@ -0,0 +1,11 @@
+/**
+ * Escapes text for safe interpolation into email/notification HTML, so
+ * user- or scraper-supplied content can never inject markup.
+ */
+export function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}