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 000000000..78de07ba0 --- /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 86a460432..6bf27bbc6 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -36,6 +36,8 @@ 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("
reply note
"); + }); + + 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("
reply note
"); + 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__/buildWelcomeEmail.test.ts b/lib/emails/__tests__/buildWelcomeEmail.test.ts index ef4cdc9f5..f077a6bf5 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__/describeCronCadence.test.ts b/lib/emails/__tests__/describeCronCadence.test.ts new file mode 100644 index 000000000..c1a6ce936 --- /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/__tests__/processAndSendEmail.test.ts b/lib/emails/__tests__/processAndSendEmail.test.ts index f4940b133..774998a36 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 000000000..9ae510b76 --- /dev/null +++ b/lib/emails/__tests__/renderEmailLayout.test.ts @@ -0,0 +1,69 @@ +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("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/buildColdStartNudgeEmail.ts b/lib/emails/buildColdStartNudgeEmail.ts new file mode 100644 index 000000000..76cf63231 --- /dev/null +++ b/lib/emails/buildColdStartNudgeEmail.ts @@ -0,0 +1,32 @@ +import { CHAT_APP_URL } from "@/lib/const"; +import { getEmailFooter } from "@/lib/emails/getEmailFooter"; +import { renderEmailLayout } from "@/lib/emails/renderEmailLayout"; + +/** + * Nudge for an account that was welcomed but never added an artist (chat#1889). + * + * The welcome email fires on account creation regardless of whether a valuation + * preceded it, so a cold-start signup was told to "confirm your artists" and + * "see your baseline valuation" for records that do not exist. This email asks + * for the one thing that unblocks everything else: pick the artist. + * + * Chrome comes from the shared `renderEmailLayout` (api#784). Copy avoids em/en + * dashes. + */ +export function buildColdStartNudgeEmail(): { subject: string; html: string } { + const bodyHtml = `

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/buildScheduleConfirmationEmail.ts b/lib/emails/buildScheduleConfirmationEmail.ts new file mode 100644 index 000000000..19d7c743f --- /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/buildWelcomeEmail.ts b/lib/emails/buildWelcomeEmail.ts index 642295ca7..476c3a335 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.

-
Recoup
+ 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)} -
Confirm your roster →
-${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/describeCronCadence.ts b/lib/emails/describeCronCadence.ts new file mode 100644 index 000000000..3491a5f2a --- /dev/null +++ b/lib/emails/describeCronCadence.ts @@ -0,0 +1,47 @@ +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/processAndSendEmail.ts b/lib/emails/processAndSendEmail.ts index 16402cd5b..f3e4a4452 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 000000000..4d911a124 --- /dev/null +++ b/lib/emails/renderEmailLayout.ts @@ -0,0 +1,88 @@ +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. +// +// 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"; + +// 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 + ? ` +
+ + ${cta.label} + +
`.trim() + : ""; + + const footerBlock = footerHtml + ? ` +
+ ${footerHtml} +
`.trim() + : ""; + + return ` +
+
+ ${header} +
+ ${bodyHtml} +
+ ${ctaBlock} + ${footerBlock} +
+
+ Recoup, the AI agent platform for the music industry +
+
`.trim(); +} diff --git a/lib/emails/sendColdStartNudgeEmail.ts b/lib/emails/sendColdStartNudgeEmail.ts new file mode 100644 index 000000000..9c1a21fa7 --- /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/emails/sendScheduleConfirmationEmail.ts b/lib/emails/sendScheduleConfirmationEmail.ts new file mode 100644 index 000000000..f7732fecd --- /dev/null +++ b/lib/emails/sendScheduleConfirmationEmail.ts @@ -0,0 +1,87 @@ +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/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts b/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts index bcde9497e..38fa59c9d 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 3325f7e02..60cd51886 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}

-
Recoup
+ const bodyHtml = `

Catalog valuation

+

${name}

${renderArtistHeader(params.artist)} ${renderValuationBlock(params.valuation)} ${renderStatRow(params)} ${renderReleasesTable(params.releases)} -${disclaimer} -
Get the full report with Recoup →
-

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 }; } diff --git a/lib/onboarding/__tests__/getColdStartAccountIds.test.ts b/lib/onboarding/__tests__/getColdStartAccountIds.test.ts new file mode 100644 index 000000000..1f1fb9262 --- /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 000000000..0ce358cd6 --- /dev/null +++ b/lib/onboarding/__tests__/runColdStartNudgeSweep.test.ts @@ -0,0 +1,95 @@ +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 000000000..a2280bbdc --- /dev/null +++ b/lib/onboarding/coldStartNudgeHandler.ts @@ -0,0 +1,35 @@ +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 000000000..f60bb8642 --- /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 000000000..7d14e2a72 --- /dev/null +++ b/lib/onboarding/runColdStartNudgeSweep.ts @@ -0,0 +1,81 @@ +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 000000000..3dd490045 --- /dev/null +++ b/lib/supabase/account_artist_ids/selectRosteredAccountIds.ts @@ -0,0 +1,29 @@ +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 0c80bd7ab..4cc6af47d 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/lib/tasks/__tests__/createTaskHandler.test.ts b/lib/tasks/__tests__/createTaskHandler.test.ts index 63cd08bf7..982a18a12 100644 --- a/lib/tasks/__tests__/createTaskHandler.test.ts +++ b/lib/tasks/__tests__/createTaskHandler.test.ts @@ -12,6 +12,11 @@ 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 +62,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 3caa13b94..592b51cd0 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