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__/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__/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/__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..9ae510b7 --- /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/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/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.

-
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 00000000..3491a5f2 --- /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 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..4d911a12 --- /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/sendScheduleConfirmationEmail.ts b/lib/emails/sendScheduleConfirmationEmail.ts new file mode 100644 index 00000000..f7732fec --- /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 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}

-
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/tasks/__tests__/createTaskHandler.test.ts b/lib/tasks/__tests__/createTaskHandler.test.ts index 63cd08bf..982a18a1 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 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