diff --git a/app/api/catalogs/[catalogId]/email-report/__tests__/route.test.ts b/app/api/catalogs/[catalogId]/email-report/__tests__/route.test.ts new file mode 100644 index 000000000..08361bd27 --- /dev/null +++ b/app/api/catalogs/[catalogId]/email-report/__tests__/route.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "../route"; +import { sendCatalogReportEmail } from "@/lib/catalog/emailReport/sendCatalogReportEmail"; + +vi.mock("@/lib/catalog/emailReport/sendCatalogReportEmail", () => ({ + sendCatalogReportEmail: vi.fn(), +})); + +const mockSend = vi.mocked(sendCatalogReportEmail); + +const CATALOG_ID = "9f3a2c9c-1b2d-4e5f-8a7b-6c5d4e3f2a1b"; + +const makeRequest = (body: unknown) => + new Request( + `http://localhost/api/catalogs/${CATALOG_ID}/email-report`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }, + ); + +const params = (catalogId: string) => ({ + params: Promise.resolve({ catalogId }), +}); + +describe("POST /api/catalogs/[catalogId]/email-report", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends the report email and returns 200", async () => { + mockSend.mockResolvedValueOnce(true); + + const response = await POST( + makeRequest({ email: "fan@example.com", headline_value: 1400000 }), + params(CATALOG_ID), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ success: true }); + expect(mockSend).toHaveBeenCalledWith({ + email: "fan@example.com", + catalogId: CATALOG_ID, + headlineValue: 1400000, + }); + }); + + it("returns 400 for an invalid email", async () => { + const response = await POST( + makeRequest({ email: "nope" }), + params(CATALOG_ID), + ); + + expect(response.status).toBe(400); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it("returns 400 for a non-uuid catalog id", async () => { + const response = await POST( + makeRequest({ email: "fan@example.com" }), + params("not-a-uuid"), + ); + + expect(response.status).toBe(400); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it("returns 400 for a malformed JSON body", async () => { + const response = await POST(makeRequest("{nope"), params(CATALOG_ID)); + + expect(response.status).toBe(400); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it("returns 502 when the email send fails", async () => { + mockSend.mockResolvedValueOnce(false); + + const response = await POST( + makeRequest({ email: "fan@example.com" }), + params(CATALOG_ID), + ); + + expect(response.status).toBe(502); + }); +}); diff --git a/app/api/catalogs/[catalogId]/email-report/route.ts b/app/api/catalogs/[catalogId]/email-report/route.ts new file mode 100644 index 000000000..02de4e01e --- /dev/null +++ b/app/api/catalogs/[catalogId]/email-report/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateEmailReportBody } from "@/lib/catalog/emailReport/validateEmailReportBody"; +import { sendCatalogReportEmail } from "@/lib/catalog/emailReport/sendCatalogReportEmail"; + +const catalogIdSchema = z.string().uuid(); + +/** + * POST /api/catalogs/[catalogId]/email-report — "Email me this report" + * (chat#1902 item C3). Deliberately unauthenticated: the report page is + * publicly viewable and this is the page's capture, so anonymous viewers must + * be able to use it. Sends the viewer the report link (plus the headline + * value they saw) via the chat repo's Resend path. The viewer's email is + * used for the send only, never logged. + */ +export async function POST( + request: Request, + context: { params: Promise<{ catalogId: string }> }, +): Promise { + const { catalogId } = await context.params; + if (!catalogIdSchema.safeParse(catalogId).success) { + return NextResponse.json({ error: "Invalid catalog id" }, { status: 400 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const validated = validateEmailReportBody(body); + if (validated.error !== undefined) { + return NextResponse.json({ error: validated.error }, { status: 400 }); + } + + const sent = await sendCatalogReportEmail({ + email: validated.data.email, + catalogId, + headlineValue: validated.data.headline_value, + }); + if (!sent) { + return NextResponse.json( + { error: "Failed to send the report email" }, + { status: 502 }, + ); + } + + return NextResponse.json({ success: true }); +} + +export const dynamic = "force-dynamic"; diff --git a/components/Catalog/report/CatalogReportContent.tsx b/components/Catalog/report/CatalogReportContent.tsx index de8ca1c52..ecd166a43 100644 --- a/components/Catalog/report/CatalogReportContent.tsx +++ b/components/Catalog/report/CatalogReportContent.tsx @@ -13,6 +13,7 @@ import CatalogReportInsights from "./CatalogReportInsights"; import CatalogReportCta from "./CatalogReportCta"; import CatalogReportSkeleton from "./CatalogReportSkeleton"; import CatalogReportError from "./CatalogReportError"; +import CatalogReportEmailCapture from "./CatalogReportEmailCapture"; interface CatalogReportContentProps { catalogId: string; @@ -46,7 +47,14 @@ const CatalogReportContent = ({ catalogId }: CatalogReportContentProps) => { return ; } if (!measurements) { - return ; + // Anonymous viewers can land here (the measurements query needs auth), so + // the capture must still render: the emailed link brings them back. + return ( +
+ + +
+ ); } const totalStreams = @@ -70,6 +78,10 @@ const CatalogReportContent = ({ catalogId }: CatalogReportContentProps) => { return (
+ { + const [email, setEmail] = useState(""); + const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">( + "idle", + ); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setStatus("sending"); + try { + const response = await fetch( + `/api/catalogs/${catalogId}/email-report`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, headline_value: headlineValue }), + }, + ); + setStatus(response.ok ? "sent" : "error"); + } catch { + setStatus("error"); + } + }; + + return ( +
+ {status === "sent" ? ( +

+ Report sent. Check your inbox. +

+ ) : ( + <> +

+ Email me this report +

+

+ Get the link and the headline number in your inbox so you can come + back to it anytime. +

+
+ setEmail(event.target.value)} + placeholder="you@example.com" + aria-label="Your email address" + className="w-full rounded-xl bg-background px-4 py-2.5 text-sm text-foreground shadow-[0_0_0_1px_var(--border)] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + +
+ {status === "error" && ( +

+ Could not send the email. Check the address and try again. +

+ )} + + )} +
+ ); +}; + +export default CatalogReportEmailCapture; diff --git a/lib/catalog/emailReport/__tests__/renderReportEmailHtml.test.ts b/lib/catalog/emailReport/__tests__/renderReportEmailHtml.test.ts new file mode 100644 index 000000000..34a9d9790 --- /dev/null +++ b/lib/catalog/emailReport/__tests__/renderReportEmailHtml.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { renderReportEmailHtml } from "../renderReportEmailHtml"; + +const REPORT_URL = + "https://chat.recoupable.dev/catalogs/9f3a2c9c-1b2d-4e5f-8a7b-6c5d4e3f2a1b"; + +describe("renderReportEmailHtml", () => { + it("links to the report", () => { + const html = renderReportEmailHtml({ reportUrl: REPORT_URL }); + expect(html).toContain(`href="${REPORT_URL}"`); + }); + + it("shows the formatted headline value when provided", () => { + const html = renderReportEmailHtml({ + reportUrl: REPORT_URL, + headlineValue: 1400000, + }); + expect(html).toContain("$1.4M"); + }); + + it("omits the value line when no headline value is provided", () => { + const html = renderReportEmailHtml({ reportUrl: REPORT_URL }); + expect(html).not.toContain("Estimated catalog value"); + }); + + it("contains no em or en dashes", () => { + const html = renderReportEmailHtml({ + reportUrl: REPORT_URL, + headlineValue: 959000, + }); + expect(html).not.toMatch(/[–—]/); + }); +}); diff --git a/lib/catalog/emailReport/__tests__/sendCatalogReportEmail.test.ts b/lib/catalog/emailReport/__tests__/sendCatalogReportEmail.test.ts new file mode 100644 index 000000000..0b9ec4457 --- /dev/null +++ b/lib/catalog/emailReport/__tests__/sendCatalogReportEmail.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { sendCatalogReportEmail } from "../sendCatalogReportEmail"; +import sendEmail from "@/lib/email/sendEmail"; +import { APP_BASE_URL, RECOUP_FROM_EMAIL } from "@/lib/consts"; + +vi.mock("@/lib/email/sendEmail", () => ({ default: vi.fn() })); + +const mockSendEmail = vi.mocked(sendEmail); + +const CATALOG_ID = "9f3a2c9c-1b2d-4e5f-8a7b-6c5d4e3f2a1b"; + +describe("sendCatalogReportEmail", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends from the Recoup address to the viewer with the report link", async () => { + mockSendEmail.mockResolvedValueOnce({ ok: true } as Response); + + const sent = await sendCatalogReportEmail({ + email: "fan@example.com", + catalogId: CATALOG_ID, + headlineValue: 1400000, + }); + + expect(sent).toBe(true); + expect(mockSendEmail).toHaveBeenCalledTimes(1); + const args = mockSendEmail.mock.calls[0][0]; + expect(args.from).toBe(RECOUP_FROM_EMAIL); + expect(args.to).toBe("fan@example.com"); + expect(args.subject).toContain("$1.4M"); + expect(args.html).toContain(`${APP_BASE_URL}/catalogs/${CATALOG_ID}`); + }); + + it("uses a generic subject when no headline value is provided", async () => { + mockSendEmail.mockResolvedValueOnce({ ok: true } as Response); + + await sendCatalogReportEmail({ + email: "fan@example.com", + catalogId: CATALOG_ID, + }); + + const args = mockSendEmail.mock.calls[0][0]; + expect(args.subject).toBe("Your catalog valuation report"); + }); + + it("returns false when the send fails", async () => { + mockSendEmail.mockResolvedValueOnce({ ok: false } as Response); + + const sent = await sendCatalogReportEmail({ + email: "fan@example.com", + catalogId: CATALOG_ID, + }); + + expect(sent).toBe(false); + }); + + it("returns false when the send path returns an empty result", async () => { + mockSendEmail.mockResolvedValueOnce(""); + + const sent = await sendCatalogReportEmail({ + email: "fan@example.com", + catalogId: CATALOG_ID, + }); + + expect(sent).toBe(false); + }); +}); diff --git a/lib/catalog/emailReport/__tests__/validateEmailReportBody.test.ts b/lib/catalog/emailReport/__tests__/validateEmailReportBody.test.ts new file mode 100644 index 000000000..c45ba0a56 --- /dev/null +++ b/lib/catalog/emailReport/__tests__/validateEmailReportBody.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { validateEmailReportBody } from "../validateEmailReportBody"; + +describe("validateEmailReportBody", () => { + it("accepts a valid email and trims whitespace", () => { + const result = validateEmailReportBody({ email: " fan@example.com " }); + expect(result.data).toEqual({ email: "fan@example.com" }); + expect(result.error).toBeUndefined(); + }); + + it("accepts an optional positive headline_value", () => { + const result = validateEmailReportBody({ + email: "fan@example.com", + headline_value: 1400000, + }); + expect(result.data).toEqual({ + email: "fan@example.com", + headline_value: 1400000, + }); + }); + + it("rejects a missing email", () => { + const result = validateEmailReportBody({}); + expect(result.data).toBeUndefined(); + expect(result.error).toBeTruthy(); + }); + + it("rejects an invalid email", () => { + const result = validateEmailReportBody({ email: "not-an-email" }); + expect(result.data).toBeUndefined(); + expect(result.error).toBeTruthy(); + }); + + it("rejects a non-numeric headline_value", () => { + const result = validateEmailReportBody({ + email: "fan@example.com", + headline_value: "1.4M", + }); + expect(result.data).toBeUndefined(); + expect(result.error).toBeTruthy(); + }); + + it("rejects a negative headline_value", () => { + const result = validateEmailReportBody({ + email: "fan@example.com", + headline_value: -5, + }); + expect(result.data).toBeUndefined(); + expect(result.error).toBeTruthy(); + }); + + it("rejects a non-object body", () => { + const result = validateEmailReportBody("hello"); + expect(result.data).toBeUndefined(); + expect(result.error).toBeTruthy(); + }); +}); diff --git a/lib/catalog/emailReport/renderReportEmailHtml.ts b/lib/catalog/emailReport/renderReportEmailHtml.ts new file mode 100644 index 000000000..5dcec5c83 --- /dev/null +++ b/lib/catalog/emailReport/renderReportEmailHtml.ts @@ -0,0 +1,35 @@ +import { formatValuationAmount } from "@/lib/catalog/formatValuationAmount"; + +/** + * Renders the "Email me this report" body: the headline value the viewer just + * saw (when the report had one) plus a single button back to the live report. + * Achromatic house style; every dynamic value is server-built (uuid-derived + * URL, number formatted here), so nothing viewer-typed reaches the HTML. + */ +export function renderReportEmailHtml(params: { + reportUrl: string; + headlineValue?: number; +}): string { + const valueBlock = + params.headlineValue !== undefined + ? `

Estimated catalog value

+

${formatValuationAmount(params.headlineValue)}

` + : ""; + + return ` +
+

+ Here is the catalog valuation report you asked for. The live report stays + up to date as new play counts are measured. +

+ ${valueBlock} + + View your report + +

+ Directional model, not an appraisal. Sent by Recoup because you requested + this report at chat.recoupable.dev. +

+
`; +} diff --git a/lib/catalog/emailReport/sendCatalogReportEmail.ts b/lib/catalog/emailReport/sendCatalogReportEmail.ts new file mode 100644 index 000000000..e9b3acd44 --- /dev/null +++ b/lib/catalog/emailReport/sendCatalogReportEmail.ts @@ -0,0 +1,35 @@ +import sendEmail from "@/lib/email/sendEmail"; +import { APP_BASE_URL, RECOUP_FROM_EMAIL } from "@/lib/consts"; +import { formatValuationAmount } from "@/lib/catalog/formatValuationAmount"; +import { renderReportEmailHtml } from "@/lib/catalog/emailReport/renderReportEmailHtml"; + +/** + * Sends the catalog report link (and headline value when the viewer's report + * had one) to the address an anonymous viewer typed on /catalogs/[catalogId] + * (chat#1902 item C3). Goes out through the chat repo's direct Resend path. + * + * @returns true when Resend accepted the send + */ +export async function sendCatalogReportEmail(params: { + email: string; + catalogId: string; + headlineValue?: number; +}): Promise { + const reportUrl = `${APP_BASE_URL}/catalogs/${params.catalogId}`; + const subject = + params.headlineValue !== undefined + ? `Your catalog valuation report: ${formatValuationAmount(params.headlineValue)}` + : "Your catalog valuation report"; + + const response = await sendEmail({ + from: RECOUP_FROM_EMAIL, + to: params.email, + subject, + html: renderReportEmailHtml({ + reportUrl, + headlineValue: params.headlineValue, + }), + }); + + return typeof response !== "string" && response.ok; +} diff --git a/lib/catalog/emailReport/validateEmailReportBody.ts b/lib/catalog/emailReport/validateEmailReportBody.ts new file mode 100644 index 000000000..7c93c795b --- /dev/null +++ b/lib/catalog/emailReport/validateEmailReportBody.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +const emailReportBodySchema = z.object({ + email: z.string().trim().email().max(320), + headline_value: z.number().finite().positive().optional(), +}); + +export type EmailReportBody = z.infer; + +/** + * Validates the anonymous "Email me this report" body (chat#1902 item C3). + * The endpoint is unauthenticated, so nothing beyond the viewer's own email + * and an optional headline number is accepted. + */ +export function validateEmailReportBody( + body: unknown, +): { data: EmailReportBody; error?: undefined } | { data?: undefined; error: string } { + const result = emailReportBodySchema.safeParse(body); + if (!result.success) { + return { error: result.error.issues[0]?.message ?? "Invalid body" }; + } + return { data: result.data }; +}