-
Notifications
You must be signed in to change notification settings - Fork 10
feat(emails): cold-start nudge sweep for welcomed accounts with no artist (chat#1889 row 15) #789
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sweetmantech
wants to merge
9
commits into
main
Choose a base branch
from
feat/cold-start-nudge-sweep
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
7eee332
feat(emails): shared house-style email layout wrapper (consistency pass)
sweetmantech 7ec6867
Merge remote-tracking branch 'origin/main' into feat/shared-email-layout
sweetmantech 40df59d
feat(emails): adopt the shared layout in welcome + valuation; fix fon…
sweetmantech e3fbd06
style: prettier fix in renderEmailLayout regression test
sweetmantech dc8bbc6
feat(emails): confirm a new schedule to the account holder
sweetmantech 1088708
feat(emails): cold-start nudge sweep for welcomed accounts with no ar…
sweetmantech a2e00d5
style: prettier fix on schedule-confirmation email files
sweetmantech 13a3c7e
Merge remote-tracking branch 'origin/feat/schedule-confirmation-email…
sweetmantech 315050a
style: prettier fix on cold-start nudge sweep files
sweetmantech File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<NextResponse> { | ||
| return coldStartNudgeHandler(request); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
48 changes: 48 additions & 0 deletions
48
lib/emails/__tests__/buildScheduleConfirmationEmail.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { CHAT_APP_URL } from "@/lib/const"; | ||
|
|
||
| const mockGetEmailFooter = vi.fn(); | ||
| vi.mock("@/lib/emails/getEmailFooter", () => ({ | ||
| 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("<footer>reply note</footer>"); | ||
| }); | ||
|
|
||
| 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("<footer>reply note</footer>"); | ||
| 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(/[–—]/); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: "<p>Hello world</p>" }); | ||
| expect(html).toContain("<p>Hello world</p>"); | ||
| }); | ||
|
|
||
| it("includes the footer HTML when provided", () => { | ||
| const html = renderEmailLayout({ | ||
| bodyHtml: "<p>Body</p>", | ||
| footerHtml: "<div>Footer bits</div>", | ||
| }); | ||
| expect(html).toContain("<div>Footer bits</div>"); | ||
| }); | ||
|
|
||
| it("omits the footer region when no footer is provided", () => { | ||
| const html = renderEmailLayout({ bodyHtml: "<p>Body</p>" }); | ||
| expect(html).not.toContain("Footer bits"); | ||
| }); | ||
|
|
||
| it("renders a CTA button with the given label and url when provided", () => { | ||
| const html = renderEmailLayout({ | ||
| bodyHtml: "<p>Body</p>", | ||
| 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: "<p>Body</p>" }); | ||
| expect(html).not.toContain("<a "); | ||
| }); | ||
|
|
||
| it("carries the Recoup house style — wordmark, font stack, and shadow-as-border card", () => { | ||
| const html = renderEmailLayout({ bodyHtml: "<p>Body</p>" }); | ||
| // 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: "<p>Body</p>" }); | ||
|
|
||
| // 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: "<p>x</p>" })).toBe("string"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = `<p style="margin:0 0 6px;font-size:12px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#6b6b6b">One step left</p> | ||
| <h1 style="margin:0 0 20px;font-size:24px;line-height:1.2;letter-spacing:-0.02em;color:#0a0a0a">Add an artist and we will value their catalog.</h1> | ||
| <p style="margin:0 0 12px;font-size:14px;line-height:1.6;color:#0a0a0a">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.</p> | ||
| <p style="margin:0;font-size:14px;line-height:1.6;color:#6b6b6b">It takes one search. Everything else, the catalog, the valuation, and the weekly report, follows from it.</p>`; | ||
|
|
||
| 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 }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = `<p style="margin:0 0 6px;font-size:12px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#6b6b6b">Report scheduled</p> | ||
| <h1 style="margin:0 0 20px;font-size:24px;line-height:1.2;letter-spacing:-0.02em;color:#0a0a0a">${safeTitle} is set for ${safeCadence}.</h1> | ||
| <p style="margin:0 0 12px;font-size:14px;line-height:1.6;color:#0a0a0a">Recoup will run it on that schedule and email you the result, so your catalog keeps getting measured without you asking.</p> | ||
| <p style="margin:0;font-size:14px;line-height:1.6;color:#6b6b6b">You can change the cadence, pause it, or add another report any time.</p>`; | ||
|
|
||
| const html = renderEmailLayout({ | ||
| bodyHtml, | ||
| cta: { label: "View your reports →", url: `${CHAT_APP_URL}/tasks` }, | ||
| footerHtml: getEmailFooter(), | ||
| }); | ||
|
|
||
| return { subject: `Scheduled: ${title}`, html }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: Sunday schedules written with the valid
7cron value render as raw cron text instead of a Sunday cadence. Treat7as the same Sunday index as0so confirmations remain human-readable for both supported forms.Prompt for AI agents