From 7eee3320cc7e79bd650038e3f9d39f372526cc5a Mon Sep 17 00:00:00 2001
From: Sweets Sweetman
Date: Thu, 23 Jul 2026 06:28:01 -0500
Subject: [PATCH 1/7] feat(emails): shared house-style email layout wrapper
(consistency pass)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Welcome, valuation, and weekly-report emails should share one visual language
(DESIGN.md — four-font system, shadow-as-border, achromatic chrome), but each
outbound email was assembled ad hoc (body + bare footer). This establishes the
shared template (recoupable/chat#1885 consistency pass).
- `renderEmailLayout` (`lib/emails/`) — one header + footer + optional-CTA
wrapper: Recoup wordmark header, achromatic shadow-as-border card, the
DESIGN.md font stack, centered fixed-max-width container. Email-client-safe
(inline styles, literal hex tokens, webfonts degrade to system fonts).
- `processAndSendEmail` — adopts the wrapper as the proof point. This is the
path the live weekly-report email flows through (agent `send_email` →
`processAndSendEmail`), so the already-live weekly report now renders in the
shared house style, with the existing footer carried in as the layout footer.
Welcome (api#774) and valuation (api#773) emails adopt the same wrapper once
merged — this PR establishes the shared template.
TDD: RED→GREEN for the layout renderer (body/footer/CTA + house-style markers)
and for the processAndSendEmail adoption.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../__tests__/processAndSendEmail.test.ts | 18 ++++
.../__tests__/renderEmailLayout.test.ts | 54 ++++++++++++
lib/emails/processAndSendEmail.ts | 10 ++-
lib/emails/renderEmailLayout.ts | 83 +++++++++++++++++++
4 files changed, 163 insertions(+), 2 deletions(-)
create mode 100644 lib/emails/__tests__/renderEmailLayout.test.ts
create mode 100644 lib/emails/renderEmailLayout.ts
diff --git a/lib/emails/__tests__/processAndSendEmail.test.ts b/lib/emails/__tests__/processAndSendEmail.test.ts
index f4940b13..774998a3 100644
--- a/lib/emails/__tests__/processAndSendEmail.test.ts
+++ b/lib/emails/__tests__/processAndSendEmail.test.ts
@@ -94,6 +94,24 @@ describe("processAndSendEmail", () => {
);
});
+ it("wraps the email in the shared house-style layout (weekly-report consistency pass)", async () => {
+ mockSendEmailWithResend.mockResolvedValue({ id: "email-layout" });
+
+ await processAndSendEmail({
+ to: ["user@example.com"],
+ subject: "Weekly report",
+ html: "This week
",
+ });
+
+ const sent = mockSendEmailWithResend.mock.calls[0][0] as { html: string };
+ // Body preserved…
+ expect(sent.html).toContain("This week
");
+ // …inside the shared layout: Recoup wordmark header + shadow-as-border card.
+ expect(sent.html).toContain("Recoup");
+ expect(sent.html).toContain("box-shadow");
+ expect(sent.html).toContain("Plus Jakarta Sans");
+ });
+
it("returns error when Resend fails", async () => {
const errorResponse = NextResponse.json(
{ error: { message: "Rate limited" } },
diff --git a/lib/emails/__tests__/renderEmailLayout.test.ts b/lib/emails/__tests__/renderEmailLayout.test.ts
new file mode 100644
index 00000000..90609291
--- /dev/null
+++ b/lib/emails/__tests__/renderEmailLayout.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect } from "vitest";
+import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";
+
+describe("renderEmailLayout", () => {
+ it("wraps the body HTML inside the layout", () => {
+ const html = renderEmailLayout({ bodyHtml: "Hello world
" });
+ expect(html).toContain("Hello world
");
+ });
+
+ it("includes the footer HTML when provided", () => {
+ const html = renderEmailLayout({
+ bodyHtml: "Body
",
+ footerHtml: "Footer bits
",
+ });
+ expect(html).toContain("Footer bits
");
+ });
+
+ it("omits the footer region when no footer is provided", () => {
+ const html = renderEmailLayout({ bodyHtml: "Body
" });
+ expect(html).not.toContain("Footer bits");
+ });
+
+ it("renders a CTA button with the given label and url when provided", () => {
+ const html = renderEmailLayout({
+ bodyHtml: "Body
",
+ cta: { label: "Open Recoup", url: "https://chat.recoupable.dev" },
+ });
+ expect(html).toContain("Open Recoup");
+ expect(html).toContain('href="https://chat.recoupable.dev"');
+ });
+
+ it("does not render a CTA when none is provided", () => {
+ const html = renderEmailLayout({ bodyHtml: "Body
" });
+ expect(html).not.toContain(" {
+ const html = renderEmailLayout({ bodyHtml: "Body
" });
+ // Header wordmark.
+ expect(html).toContain("Recoup");
+ // Achromatic near-black brand ink (DESIGN.md --foreground).
+ expect(html).toContain("#0a0a0a");
+ // Shadow-as-border card outline (DESIGN.md), not a CSS `border` on the card.
+ expect(html).toContain("box-shadow");
+ // Brand font stack (Plus Jakarta Sans for UI, per DESIGN.md).
+ expect(html).toContain("Plus Jakarta Sans");
+ // Constrained, centered container.
+ expect(html).toContain("max-width");
+ });
+
+ it("returns a single HTML string", () => {
+ expect(typeof renderEmailLayout({ bodyHtml: "x
" })).toBe("string");
+ });
+});
diff --git a/lib/emails/processAndSendEmail.ts b/lib/emails/processAndSendEmail.ts
index 16402cd5..f3e4a445 100644
--- a/lib/emails/processAndSendEmail.ts
+++ b/lib/emails/processAndSendEmail.ts
@@ -1,5 +1,6 @@
import { sendEmailWithResend } from "@/lib/emails/sendEmail";
import { getEmailFooter } from "@/lib/emails/getEmailFooter";
+import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";
import { selectRoomWithArtist } from "@/lib/supabase/rooms/selectRoomWithArtist";
import { RECOUP_FROM_EMAIL } from "@/lib/const";
import { NextResponse } from "next/server";
@@ -43,14 +44,19 @@ export async function processAndSendEmail(
const roomData = room_id ? await selectRoomWithArtist(room_id) : null;
const footer = getEmailFooter(room_id, roomData?.artist_name || undefined);
const bodyHtml = html || (text ? await marked(text) : "");
- const htmlWithFooter = `${bodyHtml}\n\n${footer}`;
+ // Wrap in the shared house-style layout so every outbound email — including
+ // the live weekly-report send that flows through here — shares one visual
+ // language with the welcome/valuation emails (recoupable/chat#1885
+ // consistency pass): Recoup wordmark header, achromatic shadow-as-border
+ // card, DESIGN.md font stack, and the existing footer as the layout footer.
+ const htmlWithLayout = renderEmailLayout({ bodyHtml, footerHtml: footer });
const result = await sendEmailWithResend({
from: RECOUP_FROM_EMAIL,
to,
cc: cc.length > 0 ? cc : undefined,
subject,
- html: htmlWithFooter,
+ html: htmlWithLayout,
headers,
});
diff --git a/lib/emails/renderEmailLayout.ts b/lib/emails/renderEmailLayout.ts
new file mode 100644
index 00000000..bacfec4c
--- /dev/null
+++ b/lib/emails/renderEmailLayout.ts
@@ -0,0 +1,83 @@
+export type EmailLayoutCta = {
+ /** Button label. */
+ label: string;
+ /** Absolute URL the button links to. */
+ url: string;
+};
+
+export type RenderEmailLayoutParams = {
+ /** The email's main content, already rendered to HTML. */
+ bodyHtml: string;
+ /** Optional footer HTML (reply note, chat link, artist attribution). */
+ footerHtml?: string;
+ /** Optional primary call-to-action rendered as a button below the body. */
+ cta?: EmailLayoutCta;
+};
+
+// Brand tokens (DESIGN.md §3) — kept literal because email clients can't read
+// CSS custom properties. Achromatic chrome; color comes from content.
+const INK = "#0a0a0a"; // --foreground
+const MUTED_INK = "#6b6b6b"; // --muted-foreground
+const PAGE_BG = "#f7f7f7"; // --muted (page canvas)
+const CARD_BG = "#ffffff"; // --card
+const BORDER = "#e8e8e8"; // --border
+const CTA_BG = "#0a0a0a"; // --primary
+const CTA_FG = "#ffffff"; // --primary-foreground
+
+// UI font stack — Plus Jakarta Sans (DESIGN.md four-font system) with system
+// fallbacks for clients that can't load a webfont.
+const FONT_STACK =
+ '"Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
+
+// Shadow-as-border (DESIGN.md) — a hairline outline + subtle elevation via
+// box-shadow instead of a CSS `border` on the card.
+const CARD_SHADOW = `box-shadow: 0px 0px 0px 1px ${BORDER}, 0px 2px 4px rgba(0,0,0,0.04);`;
+
+/**
+ * Shared house-style wrapper for all onboarding emails (welcome, valuation,
+ * weekly report — recoupable/chat#1885 consistency pass). Wraps a rendered
+ * body in one header + footer + CTA structure so every automated email reads
+ * as one family: achromatic chrome, shadow-as-border card, the Recoup wordmark
+ * header, and the DESIGN.md font stack.
+ *
+ * Email-client-safe: all styles inline, a centered fixed-max-width container,
+ * literal hex tokens (no CSS variables), and webfonts degrade to system fonts.
+ */
+export function renderEmailLayout({ bodyHtml, footerHtml, cta }: RenderEmailLayoutParams): string {
+ const header = `
+
+ Recoup
+
`.trim();
+
+ const ctaBlock = cta
+ ? `
+`.trim()
+ : "";
+
+ const footerBlock = footerHtml
+ ? `
+
+ ${footerHtml}
+
`.trim()
+ : "";
+
+ return `
+
+
+ ${header}
+
+ ${bodyHtml}
+
+ ${ctaBlock}
+ ${footerBlock}
+
+
+ Recoup — the AI agent platform for the music industry
+
+
`.trim();
+}
From 40df59d96af86f79e541f0b1eb00ae03a001e547 Mon Sep 17 00:00:00 2001
From: Sweets Sweetman
Date: Thu, 23 Jul 2026 20:14:00 -0500
Subject: [PATCH 2/7] feat(emails): adopt the shared layout in welcome +
valuation; fix font-stack quoting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Completes the consistency pass — previously the wrapper was only adopted in the
weekly-report path, so welcome/valuation still shipped their own chrome.
- buildWelcomeEmail + renderValuationReportHtml now render through
renderEmailLayout (body + cta + footer), dropping their duplicated outer
page/card chrome; each keeps only its own content.
- Fix: FONT_STACK used double-quoted font names inside double-quoted
style="…" attributes, which terminated the attribute early — silently
dropping the font (clients fell back to serif) AND every declaration after
it (the CTA button lost its background/padding/radius). Single-quote the
font names; add a regression test.
- Layout footer tagline: em dash -> comma (house copy rule).
- Consistency assertions added to both email tests.
199 lib/emails tests pass; lint + format clean.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../__tests__/buildWelcomeEmail.test.ts | 10 +++++
.../__tests__/renderEmailLayout.test.ts | 15 +++++++
lib/emails/buildWelcomeEmail.ts | 36 +++++++---------
lib/emails/renderEmailLayout.ts | 9 +++-
.../renderValuationReportHtml.test.ts | 10 +++++
.../renderValuationReportHtml.ts | 41 +++++++++----------
6 files changed, 77 insertions(+), 44 deletions(-)
diff --git a/lib/emails/__tests__/buildWelcomeEmail.test.ts b/lib/emails/__tests__/buildWelcomeEmail.test.ts
index ef4cdc9f..f077a6bf 100644
--- a/lib/emails/__tests__/buildWelcomeEmail.test.ts
+++ b/lib/emails/__tests__/buildWelcomeEmail.test.ts
@@ -75,6 +75,16 @@ describe("buildWelcomeEmail", () => {
}
});
+ it("renders inside the shared house layout (consistency pass)", () => {
+ const { html } = buildWelcomeEmail();
+
+ // Shared wrapper markers: the house footer tagline + shadow-as-border card.
+ expect(html).toContain("the AI agent platform for the music industry");
+ expect(html).toContain("box-shadow: 0px 0px 0px 1px #e8e8e8");
+ // The email no longer ships its own outer page/card chrome.
+ expect(html).not.toContain("background:#f7f7f7;padding:24px 0");
+ });
+
it("uses only durable image hosts (no expiring social CDNs)", () => {
const { html } = buildWelcomeEmail();
diff --git a/lib/emails/__tests__/renderEmailLayout.test.ts b/lib/emails/__tests__/renderEmailLayout.test.ts
index 90609291..e6369c0c 100644
--- a/lib/emails/__tests__/renderEmailLayout.test.ts
+++ b/lib/emails/__tests__/renderEmailLayout.test.ts
@@ -48,6 +48,21 @@ describe("renderEmailLayout", () => {
expect(html).toContain("max-width");
});
+ it("never breaks a style attribute with quotes in the font stack", () => {
+ const html = renderEmailLayout({ bodyHtml: "Body
" });
+
+ // Regression: double-quoted font names inside a double-quoted style="…"
+ // attribute terminate it early, silently dropping the font (clients fall
+ // back to serif) and every declaration after it. Font names must be
+ // single-quoted so the attribute stays intact.
+ expect(html).not.toContain('"Plus Jakarta Sans"');
+ expect(html).toContain("'Plus Jakarta Sans'");
+ // No style attribute may contain a stray double quote before its close.
+ for (const style of html.match(/style="[^"]*"/g) ?? []) {
+ expect(style).not.toContain("font-family:\"");
+ }
+ });
+
it("returns a single HTML string", () => {
expect(typeof renderEmailLayout({ bodyHtml: "x
" })).toBe("string");
});
diff --git a/lib/emails/buildWelcomeEmail.ts b/lib/emails/buildWelcomeEmail.ts
index 642295ca..476c3a33 100644
--- a/lib/emails/buildWelcomeEmail.ts
+++ b/lib/emails/buildWelcomeEmail.ts
@@ -1,9 +1,8 @@
-import { CHAT_APP_URL, RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const";
+import { CHAT_APP_URL } from "@/lib/const";
import { getEmailFooter } from "@/lib/emails/getEmailFooter";
+import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";
import { renderWelcomeSteps } from "@/lib/emails/welcome/renderWelcomeSteps";
-const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif";
-
/**
* Builds the welcome email sent to every new account. Instead of a generic
* greeting, it walks the signup through Recoup's onboarding flow in five steps
@@ -11,9 +10,10 @@ const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif";
* catalog, see baseline valuation, automate with tasks), illustrated with art
* from the house cast of artists (PFPs + album covers, stable Spotify CDN URLs).
*
- * House style follows DESIGN.md / the valuation email: achromatic chrome
- * (#0a0a0a on #ffffff, #e8e8e8 borders, #6b6b6b muted), tables + inline styles
- * only, system font stack. Copy avoids em/en dashes.
+ * Chrome comes from the shared `renderEmailLayout` wrapper (consistency pass,
+ * chat#1885) — the same header / card / CTA / footer the valuation and
+ * weekly-report emails use — so this builder only owns its body content, CTA
+ * target, and footer. Copy avoids em/en dashes.
*
* Deep links use the fixed `CHAT_APP_URL` (never a derived deployment URL, same
* as the valuation email) so `/setup/*` always resolves to the real chat app,
@@ -24,22 +24,16 @@ const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif";
export function buildWelcomeEmail(): { subject: string; html: string } {
const footer = getEmailFooter();
- const html = `
-
-
-
-|
- Welcome to Recoup
-You're in. Let's build your catalog value.
- |
- |
-
+ const bodyHtml = `Welcome to Recoup
+You're in. Let's build your catalog value.
Recoup is your AI team for the music business. Here is the five step path from a new account to a catalog you measure, grow, and automate.
-${renderWelcomeSteps(CHAT_APP_URL)}
-
-${footer}
- |
- |
`;
+${renderWelcomeSteps(CHAT_APP_URL)}`;
+
+ const html = renderEmailLayout({
+ bodyHtml,
+ cta: { label: "Confirm your roster →", url: `${CHAT_APP_URL}/setup` },
+ footerHtml: footer,
+ });
return { subject: "Welcome to Recoup", html };
}
diff --git a/lib/emails/renderEmailLayout.ts b/lib/emails/renderEmailLayout.ts
index bacfec4c..4d911a12 100644
--- a/lib/emails/renderEmailLayout.ts
+++ b/lib/emails/renderEmailLayout.ts
@@ -26,8 +26,13 @@ const CTA_FG = "#ffffff"; // --primary-foreground
// UI font stack — Plus Jakarta Sans (DESIGN.md four-font system) with system
// fallbacks for clients that can't load a webfont.
+//
+// Font names are SINGLE-quoted on purpose: this stack is interpolated into
+// double-quoted `style="…"` attributes, so double quotes here would terminate
+// the attribute early — silently dropping the font (clients fall back to
+// serif) and every declaration after it.
const FONT_STACK =
- '"Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
+ "'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
// Shadow-as-border (DESIGN.md) — a hairline outline + subtle elevation via
// box-shadow instead of a CSS `border` on the card.
@@ -77,7 +82,7 @@ export function renderEmailLayout({ bodyHtml, footerHtml, cta }: RenderEmailLayo
${footerBlock}
- Recoup — the AI agent platform for the music industry
+ Recoup, the AI agent platform for the music industry
`.trim();
}
diff --git a/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts b/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts
index bcde9497..38fa59c9 100644
--- a/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts
+++ b/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts
@@ -106,4 +106,14 @@ describe("renderValuationReportHtml", () => {
expect(subject).not.toMatch(/[–—]/);
expect(html).not.toMatch(/[–—]/);
});
+
+ it("renders inside the shared house layout (consistency pass)", () => {
+ const { html } = renderValuationReportHtml(baseParams);
+
+ // Shared wrapper markers: the house footer tagline + shadow-as-border card.
+ expect(html).toContain("the AI agent platform for the music industry");
+ expect(html).toContain("box-shadow: 0px 0px 0px 1px #e8e8e8");
+ // The email no longer ships its own outer page/card chrome.
+ expect(html).not.toContain("background:#f7f7f7;padding:24px 0");
+ });
});
diff --git a/lib/emails/valuationReport/renderValuationReportHtml.ts b/lib/emails/valuationReport/renderValuationReportHtml.ts
index 3325f7e0..60cd5188 100644
--- a/lib/emails/valuationReport/renderValuationReportHtml.ts
+++ b/lib/emails/valuationReport/renderValuationReportHtml.ts
@@ -1,5 +1,5 @@
-import { RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const";
import { escapeHtml } from "@/lib/emails/escapeHtml";
+import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";
import { renderArtistHeader } from "@/lib/emails/valuationReport/renderArtistHeader";
import { renderValuationBlock } from "@/lib/emails/valuationReport/renderValuationBlock";
import { renderStatRow } from "@/lib/emails/valuationReport/renderStatRow";
@@ -7,17 +7,19 @@ import { renderReleasesTable } from "@/lib/emails/valuationReport/renderReleases
import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes";
const SUBJECT = "Your catalog valuation is ready";
-const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif";
/**
* Deterministic house-style renderer for the valuation-report email
* (recoupable/chat#1867, enriched per chat#1881): reproduces the marketing /
* chat catalog-report result so the email reinforces the same numbers a signup
* already saw — artist header, estimated value band, measured-scope stats, and
- * a per-release table with album art + proportional-share value. Styling
- * follows DESIGN.md: achromatic chrome (#0a0a0a on #ffffff, #e8e8e8 borders,
- * #6b6b6b muted), tables + inline styles only, system font stack, fixed
- * CHAT_APP_URL-based deep link (never a derived deployment URL). Copy avoids
+ * a per-release table with album art + proportional-share value.
+ *
+ * Chrome comes from the shared `renderEmailLayout` wrapper (consistency pass,
+ * chat#1885) — the same header / card / CTA / footer every automated email
+ * uses — so this renderer only owns its body content, CTA target, and footer
+ * note. Deep link is the fixed CHAT_APP_URL (never a derived deployment URL).
+ * Copy avoids
* em/en dashes and uses "to" for ranges. Per-album value is a proportional
* share of the single headline band (value = mid x streams/total), so the rows
* sum to the headline and never diverge from the funnel.
@@ -32,25 +34,22 @@ export function renderValuationReportHtml(params: ValuationReportEmailParams): {
? `Directional model, not an appraisal. Based on live Spotify play counts measured today, an annual run-rate from your catalog's lifetime average, and a master-side net royalty share times a 10 to 16x market multiple. Real statements collapse the range.
`
: "";
- const html = `
-
-
-
-|
- Catalog valuation
-${name}
- |
- |
-
+ const bodyHtml = `Catalog valuation
+${name}
${renderArtistHeader(params.artist)}
${renderValuationBlock(params.valuation)}
${renderStatRow(params)}
${renderReleasesTable(params.releases)}
-${disclaimer}
-
-You're receiving this because you ran a catalog valuation on Recoup.
- |
- |
`;
+${disclaimer}`;
+
+ const html = renderEmailLayout({
+ bodyHtml,
+ cta: {
+ label: "Get the full report with Recoup →",
+ url: params.deepLinkUrl,
+ },
+ footerHtml: `You're receiving this because you ran a catalog valuation on Recoup.
`,
+ });
return { subject: SUBJECT, html };
}
From e3fbd069651fea9de16fc8d6c4f812da2ae5c7e3 Mon Sep 17 00:00:00 2001
From: Sweets Sweetman
Date: Thu, 23 Jul 2026 20:18:43 -0500
Subject: [PATCH 3/7] style: prettier fix in renderEmailLayout regression test
---
lib/emails/__tests__/renderEmailLayout.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/emails/__tests__/renderEmailLayout.test.ts b/lib/emails/__tests__/renderEmailLayout.test.ts
index e6369c0c..9ae510b7 100644
--- a/lib/emails/__tests__/renderEmailLayout.test.ts
+++ b/lib/emails/__tests__/renderEmailLayout.test.ts
@@ -59,7 +59,7 @@ describe("renderEmailLayout", () => {
expect(html).toContain("'Plus Jakarta Sans'");
// No style attribute may contain a stray double quote before its close.
for (const style of html.match(/style="[^"]*"/g) ?? []) {
- expect(style).not.toContain("font-family:\"");
+ expect(style).not.toContain('font-family:"');
}
});
From dc8bbc66e99705f618dca453adf38ded7ad8f6b1 Mon Sep 17 00:00:00 2001
From: Sweets Sweetman
Date: Mon, 27 Jul 2026 00:10:27 -0500
Subject: [PATCH 4/7] feat(emails): confirm a new schedule to the account
holder
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
chat#1889 matrix row 14 — the last genuine gap in the email journey.
A signup finished onboarding and then heard nothing until a report arrived days
later, with no record that anything had actually been scheduled.
- New buildScheduleConfirmationEmail renders through the shared renderEmailLayout
(api#784), so it reads as one family with welcome/valuation/weekly-report.
- New describeCronCadence turns the cron into "Mondays at 13:00 UTC",
deliberately narrow: it only describes the fixed weekday/daily shapes
onboarding creates and otherwise falls back to the raw expression, since an
honest cron string beats a confidently wrong sentence about delivery time.
- New sendScheduleConfirmationEmail fires from createTaskHandler after the
schedule is materialized, deduped per task id in email_send_log, and swallows
its own failures so it can never fail task creation.
Co-Authored-By: Claude Opus 5 (1M context)
---
lib/const.ts | 1 +
.../buildScheduleConfirmationEmail.test.ts | 50 +++++++++++
.../__tests__/describeCronCadence.test.ts | 25 ++++++
lib/emails/buildScheduleConfirmationEmail.ts | 44 +++++++++
lib/emails/describeCronCadence.ts | 53 +++++++++++
lib/emails/sendScheduleConfirmationEmail.ts | 90 +++++++++++++++++++
lib/tasks/__tests__/createTaskHandler.test.ts | 45 ++++++++++
lib/tasks/createTaskHandler.ts | 12 +++
8 files changed, 320 insertions(+)
create mode 100644 lib/emails/__tests__/buildScheduleConfirmationEmail.test.ts
create mode 100644 lib/emails/__tests__/describeCronCadence.test.ts
create mode 100644 lib/emails/buildScheduleConfirmationEmail.ts
create mode 100644 lib/emails/describeCronCadence.ts
create mode 100644 lib/emails/sendScheduleConfirmationEmail.ts
diff --git a/lib/const.ts b/lib/const.ts
index 86a46043..cd487474 100644
--- a/lib/const.ts
+++ b/lib/const.ts
@@ -36,6 +36,7 @@ export const RECOUP_FROM_EMAIL = `Agent by Recoup ({
+ getEmailFooter: (...args: unknown[]) => mockGetEmailFooter(...args),
+}));
+
+const { buildScheduleConfirmationEmail } = await import(
+ "../buildScheduleConfirmationEmail"
+);
+
+const params = {
+ title: "Weekly valuation + streams report",
+ cadence: "Mondays at 13:00 UTC",
+};
+
+describe("buildScheduleConfirmationEmail", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetEmailFooter.mockReturnValue("");
+ });
+
+ it("names the report and when it runs, so the signup knows what to expect", () => {
+ const { subject, html } = buildScheduleConfirmationEmail(params);
+
+ expect(subject).toContain("Weekly valuation + streams report");
+ expect(html).toContain("Mondays at 13:00 UTC");
+ });
+
+ it("points the CTA at the account's tasks", () => {
+ const { html } = buildScheduleConfirmationEmail(params);
+
+ expect(html).toContain(`href="${CHAT_APP_URL}/tasks"`);
+ });
+
+ it("renders through the shared layout, so it reads as one family with the others", () => {
+ const { html } = buildScheduleConfirmationEmail(params);
+
+ expect(html).toContain("");
+ expect(html).toContain("Recoup");
+ });
+
+ it("contains no em or en dashes in outward-facing copy", () => {
+ const { subject, html } = buildScheduleConfirmationEmail(params);
+
+ expect(subject).not.toMatch(/[–—]/);
+ expect(html).not.toMatch(/[–—]/);
+ });
+});
diff --git a/lib/emails/__tests__/describeCronCadence.test.ts b/lib/emails/__tests__/describeCronCadence.test.ts
new file mode 100644
index 00000000..c1a6ce93
--- /dev/null
+++ b/lib/emails/__tests__/describeCronCadence.test.ts
@@ -0,0 +1,25 @@
+import { describe, it, expect } from "vitest";
+import { describeCronCadence } from "../describeCronCadence";
+
+describe("describeCronCadence", () => {
+ it("names a weekly schedule by weekday and time", () => {
+ expect(describeCronCadence("0 13 * * 1")).toBe("Mondays at 13:00 UTC");
+ expect(describeCronCadence("30 9 * * 0")).toBe("Sundays at 09:30 UTC");
+ });
+
+ it("honours an explicit time zone instead of claiming UTC", () => {
+ expect(describeCronCadence("0 13 * * 1", "America/New_York")).toBe(
+ "Mondays at 13:00 America/New_York",
+ );
+ });
+
+ it("names a daily schedule", () => {
+ expect(describeCronCadence("0 8 * * *")).toBe("every day at 08:00 UTC");
+ });
+
+ it("falls back to the raw expression rather than inventing a cadence", () => {
+ // Better an honest cron string than a confident wrong sentence in an email.
+ expect(describeCronCadence("*/5 * * * *")).toBe("the schedule */5 * * * *");
+ expect(describeCronCadence("not a cron")).toBe("the schedule not a cron");
+ });
+});
diff --git a/lib/emails/buildScheduleConfirmationEmail.ts b/lib/emails/buildScheduleConfirmationEmail.ts
new file mode 100644
index 00000000..19d7c743
--- /dev/null
+++ b/lib/emails/buildScheduleConfirmationEmail.ts
@@ -0,0 +1,44 @@
+import { CHAT_APP_URL } from "@/lib/const";
+import { escapeHtml } from "@/lib/emails/escapeHtml";
+import { getEmailFooter } from "@/lib/emails/getEmailFooter";
+import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";
+
+export interface ScheduleConfirmationEmailParams {
+ /** The scheduled task's title. */
+ title: string;
+ /** Human-readable cadence, e.g. "Mondays at 13:00 UTC". */
+ cadence: string;
+}
+
+/**
+ * Confirms a newly scheduled report: what it is, and when it will arrive
+ * (chat#1889).
+ *
+ * This is the bridge between signing up and the first report landing. Without
+ * it, a signup finishes onboarding and then hears nothing until a report shows
+ * up days later, with no record that anything was actually scheduled.
+ *
+ * Chrome comes from the shared `renderEmailLayout` (api#784), so it reads as one
+ * family with the welcome, valuation, and weekly-report emails. Copy avoids
+ * em/en dashes.
+ */
+export function buildScheduleConfirmationEmail({
+ title,
+ cadence,
+}: ScheduleConfirmationEmailParams): { subject: string; html: string } {
+ const safeTitle = escapeHtml(title);
+ const safeCadence = escapeHtml(cadence);
+
+ const bodyHtml = `Report scheduled
+${safeTitle} is set for ${safeCadence}.
+Recoup will run it on that schedule and email you the result, so your catalog keeps getting measured without you asking.
+You can change the cadence, pause it, or add another report any time.
`;
+
+ const html = renderEmailLayout({
+ bodyHtml,
+ cta: { label: "View your reports →", url: `${CHAT_APP_URL}/tasks` },
+ footerHtml: getEmailFooter(),
+ });
+
+ return { subject: `Scheduled: ${title}`, html };
+}
diff --git a/lib/emails/describeCronCadence.ts b/lib/emails/describeCronCadence.ts
new file mode 100644
index 00000000..4563ef57
--- /dev/null
+++ b/lib/emails/describeCronCadence.ts
@@ -0,0 +1,53 @@
+const WEEKDAYS = [
+ "Sundays",
+ "Mondays",
+ "Tuesdays",
+ "Wednesdays",
+ "Thursdays",
+ "Fridays",
+ "Saturdays",
+];
+
+const pad = (value: number): string => String(value).padStart(2, "0");
+
+/**
+ * Plain-English cadence for a cron schedule, for the schedule-confirmation
+ * email (chat#1889).
+ *
+ * Deliberately narrow: it only describes the two shapes onboarding actually
+ * creates (a fixed weekday time, and a fixed daily time). Anything else falls
+ * back to the raw expression, because an honest cron string in an email beats a
+ * confidently wrong sentence about when someone's report will arrive.
+ *
+ * @param schedule - Standard 5-field cron expression.
+ * @param timeZone - IANA zone the expression is interpreted in; defaults to UTC.
+ */
+export function describeCronCadence(
+ schedule: string,
+ timeZone = "UTC",
+): string {
+ const fields = schedule.trim().split(/\s+/);
+ const fallback = `the schedule ${schedule}`;
+ if (fields.length !== 5) return fallback;
+
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
+ const minuteNum = Number(minute);
+ const hourNum = Number(hour);
+
+ const isFixedTime =
+ /^\d{1,2}$/.test(minute) &&
+ /^\d{1,2}$/.test(hour) &&
+ minuteNum < 60 &&
+ hourNum < 24;
+ if (!isFixedTime || dayOfMonth !== "*" || month !== "*") return fallback;
+
+ const at = `${pad(hourNum)}:${pad(minuteNum)} ${timeZone}`;
+
+ if (dayOfWeek === "*") return `every day at ${at}`;
+
+ if (/^[0-6]$/.test(dayOfWeek)) {
+ return `${WEEKDAYS[Number(dayOfWeek)]} at ${at}`;
+ }
+
+ return fallback;
+}
diff --git a/lib/emails/sendScheduleConfirmationEmail.ts b/lib/emails/sendScheduleConfirmationEmail.ts
new file mode 100644
index 00000000..968ec0b1
--- /dev/null
+++ b/lib/emails/sendScheduleConfirmationEmail.ts
@@ -0,0 +1,90 @@
+import { NextResponse } from "next/server";
+import {
+ RECOUP_FROM_EMAIL,
+ SCHEDULE_CONFIRMATION_EMAIL_LOG_TYPE,
+} from "@/lib/const";
+import { buildScheduleConfirmationEmail } from "@/lib/emails/buildScheduleConfirmationEmail";
+import { describeCronCadence } from "@/lib/emails/describeCronCadence";
+import { sendEmailWithResend } from "@/lib/emails/sendEmail";
+import { logEmailAttempt } from "@/lib/emails/logEmailAttempt";
+import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails";
+import { selectEmailSendLog } from "@/lib/supabase/email_send_log/selectEmailSendLog";
+
+/**
+ * Confirms a newly created schedule to the account holder, and records the
+ * attempt in `email_send_log` (raw_body carries the
+ * `schedule_confirmation_email` marker plus the task id).
+ *
+ * Fires from the handler that owns the milestone, after the schedule is
+ * materialized — the same rule the welcome and valuation emails follow.
+ *
+ * Idempotent per task: a task id already marked sent is skipped, so a retried
+ * create can't double-send. Best-effort: never throws, so a Resend or DB
+ * failure can never fail task creation.
+ */
+export async function sendScheduleConfirmationEmail({
+ accountId,
+ taskId,
+ title,
+ schedule,
+ timeZone,
+}: {
+ accountId: string;
+ taskId: string;
+ title: string;
+ schedule: string;
+ timeZone?: string;
+}): Promise {
+ try {
+ const marker = `"task_id":"${taskId}"`;
+ const alreadySent = await selectEmailSendLog({
+ accountId,
+ status: "sent",
+ rawBodyLike: marker,
+ limit: 1,
+ });
+ if (alreadySent.length > 0) {
+ return;
+ }
+
+ const [accountEmail] = await selectAccountEmails({ accountIds: accountId });
+ const email = accountEmail?.email;
+ if (!email) {
+ // A wallet/phone-only login has no address to confirm to; the schedule
+ // itself is unaffected.
+ return;
+ }
+
+ const { subject, html } = buildScheduleConfirmationEmail({
+ title,
+ cadence: describeCronCadence(schedule, timeZone),
+ });
+ const rawBody = JSON.stringify({
+ type: SCHEDULE_CONFIRMATION_EMAIL_LOG_TYPE,
+ task_id: taskId,
+ to: email,
+ subject,
+ });
+
+ const result = await sendEmailWithResend({
+ from: RECOUP_FROM_EMAIL,
+ to: [email],
+ subject,
+ html,
+ });
+
+ if (result instanceof NextResponse) {
+ await logEmailAttempt({ rawBody, status: "send_failed", accountId });
+ return;
+ }
+
+ await logEmailAttempt({
+ rawBody,
+ status: "sent",
+ accountId,
+ resendId: result.id,
+ });
+ } catch (error) {
+ console.error("sendScheduleConfirmationEmail failed (swallowed):", error);
+ }
+}
diff --git a/lib/tasks/__tests__/createTaskHandler.test.ts b/lib/tasks/__tests__/createTaskHandler.test.ts
index 63cd08bf..35e50aa3 100644
--- a/lib/tasks/__tests__/createTaskHandler.test.ts
+++ b/lib/tasks/__tests__/createTaskHandler.test.ts
@@ -12,6 +12,12 @@ vi.mock("@/lib/tasks/validateCreateTaskRequest", () => ({
}));
vi.mock("@/lib/tasks/createTask", () => ({ createTask: vi.fn() }));
+const mockSendScheduleConfirmationEmail = vi.fn();
+vi.mock("@/lib/emails/sendScheduleConfirmationEmail", () => ({
+ sendScheduleConfirmationEmail: (...args: unknown[]) =>
+ mockSendScheduleConfirmationEmail(...args),
+}));
+
const ACCOUNT_A = "123e4567-e89b-12d3-a456-426614174000";
const ARTIST_ID = "323e4567-e89b-12d3-a456-426614174000";
const validated = () => ({
@@ -57,6 +63,45 @@ describe("createTaskHandler", () => {
expect(createTask).toHaveBeenCalledWith(v);
});
+ it("confirms the new schedule to the account holder", async () => {
+ const v = validated();
+ vi.mocked(validateCreateTaskRequest).mockResolvedValue(v);
+ const created = { id: "sched-1", ...v } as Awaited>;
+ vi.mocked(createTask).mockResolvedValue(created);
+
+ await createTaskHandler(
+ new NextRequest("http://localhost/api/tasks", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "x-api-key": "k" },
+ body: "{}",
+ }),
+ );
+
+ // Bridges signup to the first report landing (chat#1889).
+ expect(mockSendScheduleConfirmationEmail).toHaveBeenCalledWith({
+ accountId: ACCOUNT_A,
+ taskId: "sched-1",
+ title: v.title,
+ schedule: v.schedule,
+ timeZone: undefined,
+ });
+ });
+
+ it("does not confirm a schedule that was never created", async () => {
+ vi.mocked(validateCreateTaskRequest).mockResolvedValue(validated());
+ vi.mocked(createTask).mockRejectedValue(new Error("Trigger failure"));
+
+ await createTaskHandler(
+ new NextRequest("http://localhost/api/tasks", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "x-api-key": "k" },
+ body: "{}",
+ }),
+ );
+
+ expect(mockSendScheduleConfirmationEmail).not.toHaveBeenCalled();
+ });
+
it.each([
[new Error("Trigger failure"), "Trigger failure"],
["boom", "Internal server error"],
diff --git a/lib/tasks/createTaskHandler.ts b/lib/tasks/createTaskHandler.ts
index 3caa13b9..592b51cd 100644
--- a/lib/tasks/createTaskHandler.ts
+++ b/lib/tasks/createTaskHandler.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateCreateTaskRequest } from "@/lib/tasks/validateCreateTaskRequest";
import { createTask } from "@/lib/tasks/createTask";
+import { sendScheduleConfirmationEmail } from "@/lib/emails/sendScheduleConfirmationEmail";
/**
* Creates a new task (scheduled action)
@@ -28,6 +29,17 @@ export async function createTaskHandler(request: NextRequest): Promise
Date: Mon, 27 Jul 2026 00:17:01 -0500
Subject: [PATCH 5/7] feat(emails): cold-start nudge sweep for welcomed
accounts with no artist
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
chat#1889 matrix row 15 — was deferred pending detection infra; this builds it.
The welcome email fires on account creation whether or not a valuation preceded
it, so a cold-start signup is told to confirm a roster and view a valuation that
does not exist. Six of the nine accounts welcomed 2026-07-23..25 had zero
artists. There is no event to hang detection on, because the signal is the
ABSENCE of a roster some time later, so this is a scheduled sweep.
- getColdStartAccountIds (pure): welcomed, minus rostered, minus already nudged.
- selectRosteredAccountIds: the account side of account_artist_ids, which the
existing selector does not return. Fails CLOSED on a read error so a database
problem can never make every account look cold-start and mass-nudge.
- selectEmailSendLog gains createdAfter/createdBefore (additive).
- buildColdStartNudgeEmail / sendColdStartNudgeEmail on the shared layout.
- runColdStartNudgeSweep + coldStartNudgeHandler behind CRON_SECRET, wired to a
daily Vercel cron at 15:00 UTC.
Window is 1 to 14 days after the welcome: below a day the user may still be
mid-setup, past two weeks the nudge reads as spam. Dedupe is the
cold_start_nudge_email marker in email_send_log, so a cron retry cannot
double-send.
Co-Authored-By: Claude Opus 5 (1M context)
---
app/api/internal/cold-start-nudge/route.ts | 19 ++++
lib/const.ts | 1 +
lib/emails/buildColdStartNudgeEmail.ts | 32 ++++++
lib/emails/sendColdStartNudgeEmail.ts | 59 +++++++++++
.../__tests__/getColdStartAccountIds.test.ts | 45 +++++++++
.../__tests__/runColdStartNudgeSweep.test.ts | 99 +++++++++++++++++++
lib/onboarding/coldStartNudgeHandler.ts | 37 +++++++
lib/onboarding/getColdStartAccountIds.ts | 31 ++++++
lib/onboarding/runColdStartNudgeSweep.ts | 84 ++++++++++++++++
.../selectRosteredAccountIds.ts | 31 ++++++
.../email_send_log/selectEmailSendLog.ts | 6 ++
vercel.json | 4 +
12 files changed, 448 insertions(+)
create mode 100644 app/api/internal/cold-start-nudge/route.ts
create mode 100644 lib/emails/buildColdStartNudgeEmail.ts
create mode 100644 lib/emails/sendColdStartNudgeEmail.ts
create mode 100644 lib/onboarding/__tests__/getColdStartAccountIds.test.ts
create mode 100644 lib/onboarding/__tests__/runColdStartNudgeSweep.test.ts
create mode 100644 lib/onboarding/coldStartNudgeHandler.ts
create mode 100644 lib/onboarding/getColdStartAccountIds.ts
create mode 100644 lib/onboarding/runColdStartNudgeSweep.ts
create mode 100644 lib/supabase/account_artist_ids/selectRosteredAccountIds.ts
diff --git a/app/api/internal/cold-start-nudge/route.ts b/app/api/internal/cold-start-nudge/route.ts
new file mode 100644
index 00000000..78de07ba
--- /dev/null
+++ b/app/api/internal/cold-start-nudge/route.ts
@@ -0,0 +1,19 @@
+import { NextRequest, NextResponse } from "next/server";
+import { coldStartNudgeHandler } from "@/lib/onboarding/coldStartNudgeHandler";
+
+export const dynamic = "force-dynamic";
+export const fetchCache = "force-no-store";
+export const revalidate = 0;
+
+/**
+ * GET /api/internal/cold-start-nudge — daily Vercel Cron entrypoint that nudges
+ * accounts welcomed 1 to 14 days ago that still have no artist on the roster.
+ * Cron-only (CRON_SECRET bearer); deduped per account via the
+ * `cold_start_nudge_email` marker in `email_send_log`.
+ *
+ * @param request - The incoming Next.js request.
+ * @returns A NextResponse describing how many accounts were nudged.
+ */
+export async function GET(request: NextRequest): Promise {
+ return coldStartNudgeHandler(request);
+}
diff --git a/lib/const.ts b/lib/const.ts
index cd487474..6bf27bbc 100644
--- a/lib/const.ts
+++ b/lib/const.ts
@@ -37,6 +37,7 @@ export const RECOUP_FROM_EMAIL = `Agent by Recoup One step left
+Add an artist and we will value their catalog.
+Your Recoup account is ready, but there is no artist on it yet, so there is nothing for us to measure. Search for the artist you manage and we will pull their catalog and estimate what it is worth.
+It takes one search. Everything else, the catalog, the valuation, and the weekly report, follows from it.
`;
+
+ const html = renderEmailLayout({
+ bodyHtml,
+ cta: {
+ label: "Add your artist →",
+ url: `${CHAT_APP_URL}/setup/artists`,
+ },
+ footerHtml: getEmailFooter(),
+ });
+
+ return { subject: "Add an artist to see your catalog value", html };
+}
diff --git a/lib/emails/sendColdStartNudgeEmail.ts b/lib/emails/sendColdStartNudgeEmail.ts
new file mode 100644
index 00000000..9c1a21fa
--- /dev/null
+++ b/lib/emails/sendColdStartNudgeEmail.ts
@@ -0,0 +1,59 @@
+import { NextResponse } from "next/server";
+import { COLD_START_NUDGE_EMAIL_LOG_TYPE, RECOUP_FROM_EMAIL } from "@/lib/const";
+import { buildColdStartNudgeEmail } from "@/lib/emails/buildColdStartNudgeEmail";
+import { sendEmailWithResend } from "@/lib/emails/sendEmail";
+import { logEmailAttempt } from "@/lib/emails/logEmailAttempt";
+import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails";
+
+/**
+ * Sends the cold-start nudge to one account and records the attempt in
+ * `email_send_log` (raw_body carries the `cold_start_nudge_email` marker, which
+ * is also the sweep's dedupe key).
+ *
+ * The caller has already excluded accounts marked as nudged, so this does not
+ * re-check. Best-effort: never throws, so one bad address cannot abort the
+ * sweep partway through.
+ *
+ * @returns True when a send was logged as sent.
+ */
+export async function sendColdStartNudgeEmail({
+ accountId,
+}: {
+ accountId: string;
+}): Promise {
+ try {
+ const [accountEmail] = await selectAccountEmails({ accountIds: accountId });
+ const email = accountEmail?.email;
+ if (!email) return false;
+
+ const { subject, html } = buildColdStartNudgeEmail();
+ const rawBody = JSON.stringify({
+ type: COLD_START_NUDGE_EMAIL_LOG_TYPE,
+ to: email,
+ subject,
+ });
+
+ const result = await sendEmailWithResend({
+ from: RECOUP_FROM_EMAIL,
+ to: [email],
+ subject,
+ html,
+ });
+
+ if (result instanceof NextResponse) {
+ await logEmailAttempt({ rawBody, status: "send_failed", accountId });
+ return false;
+ }
+
+ await logEmailAttempt({
+ rawBody,
+ status: "sent",
+ accountId,
+ resendId: result.id,
+ });
+ return true;
+ } catch (error) {
+ console.error("sendColdStartNudgeEmail failed (swallowed):", error);
+ return false;
+ }
+}
diff --git a/lib/onboarding/__tests__/getColdStartAccountIds.test.ts b/lib/onboarding/__tests__/getColdStartAccountIds.test.ts
new file mode 100644
index 00000000..1f1fb926
--- /dev/null
+++ b/lib/onboarding/__tests__/getColdStartAccountIds.test.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect } from "vitest";
+import { getColdStartAccountIds } from "../getColdStartAccountIds";
+
+describe("getColdStartAccountIds", () => {
+ it("returns welcomed accounts that still have no artist", () => {
+ const ids = getColdStartAccountIds({
+ welcomedAccountIds: ["a", "b", "c"],
+ rosteredAccountIds: ["b"],
+ alreadyNudgedAccountIds: [],
+ });
+
+ expect(ids).toEqual(["a", "c"]);
+ });
+
+ it("never nudges the same account twice", () => {
+ const ids = getColdStartAccountIds({
+ welcomedAccountIds: ["a", "b"],
+ rosteredAccountIds: [],
+ alreadyNudgedAccountIds: ["a"],
+ });
+
+ expect(ids).toEqual(["b"]);
+ });
+
+ it("deduplicates a welcomed list that repeats an account", () => {
+ // email_send_log can hold more than one sent row per account.
+ const ids = getColdStartAccountIds({
+ welcomedAccountIds: ["a", "a", "a"],
+ rosteredAccountIds: [],
+ alreadyNudgedAccountIds: [],
+ });
+
+ expect(ids).toEqual(["a"]);
+ });
+
+ it("returns nothing when every welcomed account is activated", () => {
+ const ids = getColdStartAccountIds({
+ welcomedAccountIds: ["a", "b"],
+ rosteredAccountIds: ["a", "b"],
+ alreadyNudgedAccountIds: [],
+ });
+
+ expect(ids).toEqual([]);
+ });
+});
diff --git a/lib/onboarding/__tests__/runColdStartNudgeSweep.test.ts b/lib/onboarding/__tests__/runColdStartNudgeSweep.test.ts
new file mode 100644
index 00000000..1f3d6cfa
--- /dev/null
+++ b/lib/onboarding/__tests__/runColdStartNudgeSweep.test.ts
@@ -0,0 +1,99 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const mockSelectEmailSendLog = vi.fn();
+const mockSelectRosteredAccountIds = vi.fn();
+const mockSendColdStartNudgeEmail = vi.fn();
+
+vi.mock("@/lib/supabase/email_send_log/selectEmailSendLog", () => ({
+ selectEmailSendLog: (...args: unknown[]) => mockSelectEmailSendLog(...args),
+}));
+vi.mock("@/lib/supabase/account_artist_ids/selectRosteredAccountIds", () => ({
+ selectRosteredAccountIds: (...args: unknown[]) =>
+ mockSelectRosteredAccountIds(...args),
+}));
+vi.mock("@/lib/emails/sendColdStartNudgeEmail", () => ({
+ sendColdStartNudgeEmail: (...args: unknown[]) =>
+ mockSendColdStartNudgeEmail(...args),
+}));
+
+const { runColdStartNudgeSweep } = await import("../runColdStartNudgeSweep");
+
+const NOW = new Date("2026-07-27T15:00:00.000Z");
+
+describe("runColdStartNudgeSweep", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ mockSendColdStartNudgeEmail.mockResolvedValue(true);
+ });
+
+ it("nudges only welcomed accounts with no roster", async () => {
+ mockSelectEmailSendLog
+ .mockResolvedValueOnce([
+ { account_id: "cold-1" },
+ { account_id: "activated" },
+ { account_id: "cold-2" },
+ ])
+ .mockResolvedValueOnce([]);
+ mockSelectRosteredAccountIds.mockResolvedValue(["activated"]);
+
+ const result = await runColdStartNudgeSweep(NOW);
+
+ expect(result).toEqual({ welcomed: 3, coldStart: 2, sent: 2 });
+ expect(mockSendColdStartNudgeEmail).toHaveBeenCalledWith({
+ accountId: "cold-1",
+ });
+ expect(mockSendColdStartNudgeEmail).not.toHaveBeenCalledWith({
+ accountId: "activated",
+ });
+ });
+
+ it("skips an account already nudged, so a cron retry cannot double-send", async () => {
+ mockSelectEmailSendLog
+ .mockResolvedValueOnce([{ account_id: "cold-1" }])
+ .mockResolvedValueOnce([{ account_id: "cold-1" }]);
+ mockSelectRosteredAccountIds.mockResolvedValue([]);
+
+ const result = await runColdStartNudgeSweep(NOW);
+
+ expect(result.sent).toBe(0);
+ expect(mockSendColdStartNudgeEmail).not.toHaveBeenCalled();
+ });
+
+ it("reads a 1-to-14-day window, so it waits a day and stops after two weeks", async () => {
+ mockSelectEmailSendLog.mockResolvedValue([]);
+
+ await runColdStartNudgeSweep(NOW);
+
+ expect(mockSelectEmailSendLog).toHaveBeenCalledWith(
+ expect.objectContaining({
+ createdAfter: "2026-07-13T15:00:00.000Z",
+ createdBefore: "2026-07-26T15:00:00.000Z",
+ }),
+ );
+ });
+
+ it("does nothing when no account was welcomed in the window", async () => {
+ mockSelectEmailSendLog.mockResolvedValue([]);
+
+ const result = await runColdStartNudgeSweep(NOW);
+
+ expect(result).toEqual({ welcomed: 0, coldStart: 0, sent: 0 });
+ expect(mockSelectRosteredAccountIds).not.toHaveBeenCalled();
+ });
+
+ it("counts only sends that actually went out", async () => {
+ mockSelectEmailSendLog
+ .mockResolvedValueOnce([{ account_id: "a" }, { account_id: "b" }])
+ .mockResolvedValueOnce([]);
+ mockSelectRosteredAccountIds.mockResolvedValue([]);
+ // e.g. a wallet-only account with no email address
+ mockSendColdStartNudgeEmail
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(false);
+
+ const result = await runColdStartNudgeSweep(NOW);
+
+ expect(result).toEqual({ welcomed: 2, coldStart: 2, sent: 1 });
+ });
+});
diff --git a/lib/onboarding/coldStartNudgeHandler.ts b/lib/onboarding/coldStartNudgeHandler.ts
new file mode 100644
index 00000000..ea065e1b
--- /dev/null
+++ b/lib/onboarding/coldStartNudgeHandler.ts
@@ -0,0 +1,37 @@
+import { type NextRequest, NextResponse } from "next/server";
+import { validateCronRequest } from "@/lib/internal/validateCronRequest";
+import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
+import { runColdStartNudgeSweep } from "@/lib/onboarding/runColdStartNudgeSweep";
+
+/**
+ * GET /api/internal/cold-start-nudge — daily Vercel Cron entrypoint that nudges
+ * welcomed accounts which never added an artist (chat#1889). Cron-only
+ * (CRON_SECRET bearer); an empty window is a no-op.
+ *
+ * @param request - The incoming HTTP request.
+ * @returns The JSON response describing what the sweep did.
+ */
+export async function coldStartNudgeHandler(
+ request: NextRequest,
+): Promise {
+ const denied = validateCronRequest(request);
+ if (denied) return denied;
+
+ try {
+ const result = await runColdStartNudgeSweep();
+
+ return NextResponse.json(
+ { status: "success", ...result },
+ { status: 200, headers: getCorsHeaders() },
+ );
+ } catch (error) {
+ console.error("Error running cold-start nudge sweep:", error);
+ return NextResponse.json(
+ {
+ status: "error",
+ error: error instanceof Error ? error.message : "Internal server error",
+ },
+ { status: 500, headers: getCorsHeaders() },
+ );
+ }
+}
diff --git a/lib/onboarding/getColdStartAccountIds.ts b/lib/onboarding/getColdStartAccountIds.ts
new file mode 100644
index 00000000..78ef00a2
--- /dev/null
+++ b/lib/onboarding/getColdStartAccountIds.ts
@@ -0,0 +1,31 @@
+export interface GetColdStartAccountIdsParams {
+ /** Accounts with a sent welcome email in the sweep window. */
+ welcomedAccountIds: string[];
+ /** Accounts that have at least one rostered artist. */
+ rosteredAccountIds: string[];
+ /** Accounts already nudged, from the dedupe marker in `email_send_log`. */
+ alreadyNudgedAccountIds: string[];
+}
+
+/**
+ * Which welcomed accounts are still cold-start: emailed a five-step onboarding
+ * path, but with no artist on the roster (chat#1889).
+ *
+ * Two thirds of the first welcome-email cohort landed here — the welcome fires
+ * on account creation whether or not a valuation preceded it, so the email
+ * promises a roster and a valuation those accounts do not have.
+ *
+ * Pure so the selection rule is testable without the sweep's three reads.
+ */
+export function getColdStartAccountIds({
+ welcomedAccountIds,
+ rosteredAccountIds,
+ alreadyNudgedAccountIds,
+}: GetColdStartAccountIdsParams): string[] {
+ const rostered = new Set(rosteredAccountIds);
+ const nudged = new Set(alreadyNudgedAccountIds);
+
+ return [...new Set(welcomedAccountIds)].filter(
+ (accountId) => !rostered.has(accountId) && !nudged.has(accountId),
+ );
+}
diff --git a/lib/onboarding/runColdStartNudgeSweep.ts b/lib/onboarding/runColdStartNudgeSweep.ts
new file mode 100644
index 00000000..f6e57550
--- /dev/null
+++ b/lib/onboarding/runColdStartNudgeSweep.ts
@@ -0,0 +1,84 @@
+import {
+ COLD_START_NUDGE_EMAIL_LOG_TYPE,
+ WELCOME_EMAIL_LOG_TYPE,
+} from "@/lib/const";
+import { sendColdStartNudgeEmail } from "@/lib/emails/sendColdStartNudgeEmail";
+import { getColdStartAccountIds } from "@/lib/onboarding/getColdStartAccountIds";
+import { selectRosteredAccountIds } from "@/lib/supabase/account_artist_ids/selectRosteredAccountIds";
+import { selectEmailSendLog } from "@/lib/supabase/email_send_log/selectEmailSendLog";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/** Wait a day before nudging: below that, the user may still be mid-setup. */
+const MIN_AGE_DAYS = 1;
+/** Stop after two weeks: past that the nudge reads as spam, not a reminder. */
+const MAX_AGE_DAYS = 14;
+
+export interface ColdStartNudgeSweepResult {
+ welcomed: number;
+ coldStart: number;
+ sent: number;
+}
+
+/**
+ * Finds accounts that were welcomed but never added an artist, and nudges them
+ * once (chat#1889).
+ *
+ * Two thirds of the first welcome-email cohort were cold-start: the welcome
+ * fires on account creation whether or not a valuation preceded it, so those
+ * accounts were told to confirm a roster and view a valuation that did not
+ * exist. Detection is a scheduled sweep because there is no event to hang it
+ * on — the signal is the ABSENCE of a roster some time later.
+ *
+ * Dedupe is the `cold_start_nudge_email` marker in `email_send_log`, so a
+ * re-run (or a cron retry) cannot double-send.
+ *
+ * @param now - Current time, injected so the window is testable.
+ */
+export async function runColdStartNudgeSweep(
+ now: Date = new Date(),
+): Promise {
+ const welcomeRows = await selectEmailSendLog({
+ status: "sent",
+ rawBodyLike: `"type":"${WELCOME_EMAIL_LOG_TYPE}"`,
+ createdAfter: new Date(now.getTime() - MAX_AGE_DAYS * DAY_MS).toISOString(),
+ createdBefore: new Date(now.getTime() - MIN_AGE_DAYS * DAY_MS).toISOString(),
+ });
+
+ const welcomedAccountIds = welcomeRows
+ .map((row) => row.account_id)
+ .filter((id): id is string => !!id);
+
+ if (welcomedAccountIds.length === 0) {
+ return { welcomed: 0, coldStart: 0, sent: 0 };
+ }
+
+ const [rosteredAccountIds, nudgedRows] = await Promise.all([
+ selectRosteredAccountIds(welcomedAccountIds),
+ selectEmailSendLog({
+ status: "sent",
+ rawBodyLike: `"type":"${COLD_START_NUDGE_EMAIL_LOG_TYPE}"`,
+ }),
+ ]);
+
+ const coldStart = getColdStartAccountIds({
+ welcomedAccountIds,
+ rosteredAccountIds,
+ alreadyNudgedAccountIds: nudgedRows
+ .map((row) => row.account_id)
+ .filter((id): id is string => !!id),
+ });
+
+ let sent = 0;
+ for (const accountId of coldStart) {
+ // Sequential on purpose: a nudge sweep has no latency requirement, and this
+ // keeps the send rate gentle.
+ if (await sendColdStartNudgeEmail({ accountId })) sent += 1;
+ }
+
+ return {
+ welcomed: new Set(welcomedAccountIds).size,
+ coldStart: coldStart.length,
+ sent,
+ };
+}
diff --git a/lib/supabase/account_artist_ids/selectRosteredAccountIds.ts b/lib/supabase/account_artist_ids/selectRosteredAccountIds.ts
new file mode 100644
index 00000000..8d58a105
--- /dev/null
+++ b/lib/supabase/account_artist_ids/selectRosteredAccountIds.ts
@@ -0,0 +1,31 @@
+import supabase from "../serverClient";
+
+/**
+ * Of the given accounts, which have at least one rostered artist.
+ *
+ * The sibling `selectAccountArtistIds` returns only `artist_id`, so it can't
+ * answer "which of these accounts is activated" — the cold-start nudge sweep
+ * needs the account side of the link (chat#1889).
+ *
+ * @param accountIds - Accounts to check.
+ * @returns The subset that has at least one artist (deduplicated).
+ */
+export async function selectRosteredAccountIds(
+ accountIds: string[],
+): Promise {
+ if (accountIds.length === 0) return [];
+
+ const { data, error } = await supabase
+ .from("account_artist_ids")
+ .select("account_id")
+ .in("account_id", accountIds);
+
+ if (error) {
+ console.error("Error fetching rostered account ids:", error);
+ // Fail closed: an errored read must not make every account look cold-start
+ // and trigger a mass nudge.
+ return accountIds;
+ }
+
+ return [...new Set((data ?? []).map((row) => row.account_id))];
+}
diff --git a/lib/supabase/email_send_log/selectEmailSendLog.ts b/lib/supabase/email_send_log/selectEmailSendLog.ts
index 0c80bd7a..4cc6af47 100644
--- a/lib/supabase/email_send_log/selectEmailSendLog.ts
+++ b/lib/supabase/email_send_log/selectEmailSendLog.ts
@@ -9,6 +9,10 @@ export interface SelectEmailSendLogFilters {
status?: string;
/** Substring matched within `raw_body` (LIKE `%value%`) — pass the marker the send was keyed on. */
rawBodyLike?: string;
+ /** Only rows created at or after this ISO timestamp. */
+ createdAfter?: string;
+ /** Only rows created at or before this ISO timestamp. */
+ createdBefore?: string;
/** Cap the number of rows returned. */
limit?: number;
}
@@ -29,6 +33,8 @@ export async function selectEmailSendLog(
if (filters.accountId) query = query.eq("account_id", filters.accountId);
if (filters.status) query = query.eq("status", filters.status);
if (filters.rawBodyLike) query = query.like("raw_body", `%${filters.rawBodyLike}%`);
+ if (filters.createdAfter) query = query.gte("created_at", filters.createdAfter);
+ if (filters.createdBefore) query = query.lte("created_at", filters.createdBefore);
if (filters.limit) query = query.limit(filters.limit);
const { data, error } = await query;
diff --git a/vercel.json b/vercel.json
index a9abbb83..7c54f513 100644
--- a/vercel.json
+++ b/vercel.json
@@ -8,6 +8,10 @@
{
"path": "/api/internal/playcount-maintenance",
"schedule": "0 7 * * *"
+ },
+ {
+ "path": "/api/internal/cold-start-nudge",
+ "schedule": "0 15 * * *"
}
]
}
From a2e00d549aac097921abeb4d4676ef97d2867268 Mon Sep 17 00:00:00 2001
From: Sweets Sweetman
Date: Mon, 27 Jul 2026 09:18:45 -0500
Subject: [PATCH 6/7] style: prettier fix on schedule-confirmation email files
Co-Authored-By: Claude Opus 5 (1M context)
---
.../__tests__/buildScheduleConfirmationEmail.test.ts | 4 +---
lib/emails/describeCronCadence.ts | 10 ++--------
lib/emails/sendScheduleConfirmationEmail.ts | 5 +----
lib/tasks/__tests__/createTaskHandler.test.ts | 3 +--
4 files changed, 5 insertions(+), 17 deletions(-)
diff --git a/lib/emails/__tests__/buildScheduleConfirmationEmail.test.ts b/lib/emails/__tests__/buildScheduleConfirmationEmail.test.ts
index 3cfa4c26..d283e754 100644
--- a/lib/emails/__tests__/buildScheduleConfirmationEmail.test.ts
+++ b/lib/emails/__tests__/buildScheduleConfirmationEmail.test.ts
@@ -6,9 +6,7 @@ vi.mock("@/lib/emails/getEmailFooter", () => ({
getEmailFooter: (...args: unknown[]) => mockGetEmailFooter(...args),
}));
-const { buildScheduleConfirmationEmail } = await import(
- "../buildScheduleConfirmationEmail"
-);
+const { buildScheduleConfirmationEmail } = await import("../buildScheduleConfirmationEmail");
const params = {
title: "Weekly valuation + streams report",
diff --git a/lib/emails/describeCronCadence.ts b/lib/emails/describeCronCadence.ts
index 4563ef57..3491a5f2 100644
--- a/lib/emails/describeCronCadence.ts
+++ b/lib/emails/describeCronCadence.ts
@@ -22,10 +22,7 @@ const pad = (value: number): string => String(value).padStart(2, "0");
* @param schedule - Standard 5-field cron expression.
* @param timeZone - IANA zone the expression is interpreted in; defaults to UTC.
*/
-export function describeCronCadence(
- schedule: string,
- timeZone = "UTC",
-): string {
+export function describeCronCadence(schedule: string, timeZone = "UTC"): string {
const fields = schedule.trim().split(/\s+/);
const fallback = `the schedule ${schedule}`;
if (fields.length !== 5) return fallback;
@@ -35,10 +32,7 @@ export function describeCronCadence(
const hourNum = Number(hour);
const isFixedTime =
- /^\d{1,2}$/.test(minute) &&
- /^\d{1,2}$/.test(hour) &&
- minuteNum < 60 &&
- hourNum < 24;
+ /^\d{1,2}$/.test(minute) && /^\d{1,2}$/.test(hour) && minuteNum < 60 && hourNum < 24;
if (!isFixedTime || dayOfMonth !== "*" || month !== "*") return fallback;
const at = `${pad(hourNum)}:${pad(minuteNum)} ${timeZone}`;
diff --git a/lib/emails/sendScheduleConfirmationEmail.ts b/lib/emails/sendScheduleConfirmationEmail.ts
index 968ec0b1..f7732fec 100644
--- a/lib/emails/sendScheduleConfirmationEmail.ts
+++ b/lib/emails/sendScheduleConfirmationEmail.ts
@@ -1,8 +1,5 @@
import { NextResponse } from "next/server";
-import {
- RECOUP_FROM_EMAIL,
- SCHEDULE_CONFIRMATION_EMAIL_LOG_TYPE,
-} from "@/lib/const";
+import { RECOUP_FROM_EMAIL, SCHEDULE_CONFIRMATION_EMAIL_LOG_TYPE } from "@/lib/const";
import { buildScheduleConfirmationEmail } from "@/lib/emails/buildScheduleConfirmationEmail";
import { describeCronCadence } from "@/lib/emails/describeCronCadence";
import { sendEmailWithResend } from "@/lib/emails/sendEmail";
diff --git a/lib/tasks/__tests__/createTaskHandler.test.ts b/lib/tasks/__tests__/createTaskHandler.test.ts
index 35e50aa3..982a18a1 100644
--- a/lib/tasks/__tests__/createTaskHandler.test.ts
+++ b/lib/tasks/__tests__/createTaskHandler.test.ts
@@ -14,8 +14,7 @@ vi.mock("@/lib/tasks/createTask", () => ({ createTask: vi.fn() }));
const mockSendScheduleConfirmationEmail = vi.fn();
vi.mock("@/lib/emails/sendScheduleConfirmationEmail", () => ({
- sendScheduleConfirmationEmail: (...args: unknown[]) =>
- mockSendScheduleConfirmationEmail(...args),
+ sendScheduleConfirmationEmail: (...args: unknown[]) => mockSendScheduleConfirmationEmail(...args),
}));
const ACCOUNT_A = "123e4567-e89b-12d3-a456-426614174000";
From 315050adc8c2b6fe7f60e4121d0957daf3a6be42 Mon Sep 17 00:00:00 2001
From: Sweets Sweetman
Date: Mon, 27 Jul 2026 09:20:23 -0500
Subject: [PATCH 7/7] style: prettier fix on cold-start nudge sweep files
Co-Authored-By: Claude Opus 5 (1M context)
---
.../__tests__/runColdStartNudgeSweep.test.ts | 10 +++-------
lib/onboarding/coldStartNudgeHandler.ts | 4 +---
lib/onboarding/getColdStartAccountIds.ts | 2 +-
lib/onboarding/runColdStartNudgeSweep.ts | 9 +++------
.../account_artist_ids/selectRosteredAccountIds.ts | 6 ++----
5 files changed, 10 insertions(+), 21 deletions(-)
diff --git a/lib/onboarding/__tests__/runColdStartNudgeSweep.test.ts b/lib/onboarding/__tests__/runColdStartNudgeSweep.test.ts
index 1f3d6cfa..0ce358cd 100644
--- a/lib/onboarding/__tests__/runColdStartNudgeSweep.test.ts
+++ b/lib/onboarding/__tests__/runColdStartNudgeSweep.test.ts
@@ -8,12 +8,10 @@ vi.mock("@/lib/supabase/email_send_log/selectEmailSendLog", () => ({
selectEmailSendLog: (...args: unknown[]) => mockSelectEmailSendLog(...args),
}));
vi.mock("@/lib/supabase/account_artist_ids/selectRosteredAccountIds", () => ({
- selectRosteredAccountIds: (...args: unknown[]) =>
- mockSelectRosteredAccountIds(...args),
+ selectRosteredAccountIds: (...args: unknown[]) => mockSelectRosteredAccountIds(...args),
}));
vi.mock("@/lib/emails/sendColdStartNudgeEmail", () => ({
- sendColdStartNudgeEmail: (...args: unknown[]) =>
- mockSendColdStartNudgeEmail(...args),
+ sendColdStartNudgeEmail: (...args: unknown[]) => mockSendColdStartNudgeEmail(...args),
}));
const { runColdStartNudgeSweep } = await import("../runColdStartNudgeSweep");
@@ -88,9 +86,7 @@ describe("runColdStartNudgeSweep", () => {
.mockResolvedValueOnce([]);
mockSelectRosteredAccountIds.mockResolvedValue([]);
// e.g. a wallet-only account with no email address
- mockSendColdStartNudgeEmail
- .mockResolvedValueOnce(true)
- .mockResolvedValueOnce(false);
+ mockSendColdStartNudgeEmail.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
const result = await runColdStartNudgeSweep(NOW);
diff --git a/lib/onboarding/coldStartNudgeHandler.ts b/lib/onboarding/coldStartNudgeHandler.ts
index ea065e1b..a2280bbd 100644
--- a/lib/onboarding/coldStartNudgeHandler.ts
+++ b/lib/onboarding/coldStartNudgeHandler.ts
@@ -11,9 +11,7 @@ import { runColdStartNudgeSweep } from "@/lib/onboarding/runColdStartNudgeSweep"
* @param request - The incoming HTTP request.
* @returns The JSON response describing what the sweep did.
*/
-export async function coldStartNudgeHandler(
- request: NextRequest,
-): Promise {
+export async function coldStartNudgeHandler(request: NextRequest): Promise {
const denied = validateCronRequest(request);
if (denied) return denied;
diff --git a/lib/onboarding/getColdStartAccountIds.ts b/lib/onboarding/getColdStartAccountIds.ts
index 78ef00a2..f60bb864 100644
--- a/lib/onboarding/getColdStartAccountIds.ts
+++ b/lib/onboarding/getColdStartAccountIds.ts
@@ -26,6 +26,6 @@ export function getColdStartAccountIds({
const nudged = new Set(alreadyNudgedAccountIds);
return [...new Set(welcomedAccountIds)].filter(
- (accountId) => !rostered.has(accountId) && !nudged.has(accountId),
+ accountId => !rostered.has(accountId) && !nudged.has(accountId),
);
}
diff --git a/lib/onboarding/runColdStartNudgeSweep.ts b/lib/onboarding/runColdStartNudgeSweep.ts
index f6e57550..7d14e2a7 100644
--- a/lib/onboarding/runColdStartNudgeSweep.ts
+++ b/lib/onboarding/runColdStartNudgeSweep.ts
@@ -1,7 +1,4 @@
-import {
- COLD_START_NUDGE_EMAIL_LOG_TYPE,
- WELCOME_EMAIL_LOG_TYPE,
-} from "@/lib/const";
+import { COLD_START_NUDGE_EMAIL_LOG_TYPE, WELCOME_EMAIL_LOG_TYPE } from "@/lib/const";
import { sendColdStartNudgeEmail } from "@/lib/emails/sendColdStartNudgeEmail";
import { getColdStartAccountIds } from "@/lib/onboarding/getColdStartAccountIds";
import { selectRosteredAccountIds } from "@/lib/supabase/account_artist_ids/selectRosteredAccountIds";
@@ -46,7 +43,7 @@ export async function runColdStartNudgeSweep(
});
const welcomedAccountIds = welcomeRows
- .map((row) => row.account_id)
+ .map(row => row.account_id)
.filter((id): id is string => !!id);
if (welcomedAccountIds.length === 0) {
@@ -65,7 +62,7 @@ export async function runColdStartNudgeSweep(
welcomedAccountIds,
rosteredAccountIds,
alreadyNudgedAccountIds: nudgedRows
- .map((row) => row.account_id)
+ .map(row => row.account_id)
.filter((id): id is string => !!id),
});
diff --git a/lib/supabase/account_artist_ids/selectRosteredAccountIds.ts b/lib/supabase/account_artist_ids/selectRosteredAccountIds.ts
index 8d58a105..3dd49004 100644
--- a/lib/supabase/account_artist_ids/selectRosteredAccountIds.ts
+++ b/lib/supabase/account_artist_ids/selectRosteredAccountIds.ts
@@ -10,9 +10,7 @@ import supabase from "../serverClient";
* @param accountIds - Accounts to check.
* @returns The subset that has at least one artist (deduplicated).
*/
-export async function selectRosteredAccountIds(
- accountIds: string[],
-): Promise {
+export async function selectRosteredAccountIds(accountIds: string[]): Promise {
if (accountIds.length === 0) return [];
const { data, error } = await supabase
@@ -27,5 +25,5 @@ export async function selectRosteredAccountIds(
return accountIds;
}
- return [...new Set((data ?? []).map((row) => row.account_id))];
+ return [...new Set((data ?? []).map(row => row.account_id))];
}