-
Notifications
You must be signed in to change notification settings - Fork 18
feat: email capture on catalog valuation report #1908
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<NextResponse> { | ||
| 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, | ||
|
Comment on lines
+37
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This endpoint trusts Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Recipients can receive an arbitrary valuation presented as the catalog report’s headline because this client-controlled number is trusted at the API boundary. Deriving the value from server-side catalog data (or omitting it unless it can be verified against the catalog) would keep the emailed report consistent with the live report. Prompt for AI agents |
||
| }); | ||
| if (!sent) { | ||
| return NextResponse.json( | ||
| { error: "Failed to send the report email" }, | ||
| { status: 502 }, | ||
| ); | ||
| } | ||
|
|
||
| return NextResponse.json({ success: true }); | ||
| } | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 <CatalogReportSkeleton />; | ||
| } | ||
| if (!measurements) { | ||
| return <CatalogReportError error={measurementsQuery.error} />; | ||
| // Anonymous viewers can land here (the measurements query needs auth), so | ||
| // the capture must still render: the emailed link brings them back. | ||
| return ( | ||
| <div className="flex flex-col gap-4 max-w-3xl"> | ||
| <CatalogReportError error={measurementsQuery.error} /> | ||
| <CatalogReportEmailCapture catalogId={catalogId} /> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the viewer is anonymous, Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: In the no-measurements branch, the email capture is now rendered even when the viewer is anonymous and Prompt for AI agents |
||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const totalStreams = | ||
|
|
@@ -70,6 +78,10 @@ const CatalogReportContent = ({ catalogId }: CatalogReportContentProps) => { | |
| return ( | ||
| <div className="flex flex-col gap-4 max-w-3xl"> | ||
| <CatalogValuationCard valuation={valuation} /> | ||
| <CatalogReportEmailCapture | ||
| catalogId={catalogId} | ||
| headlineValue={valuation.valueBand.central} | ||
| /> | ||
| <CatalogReportStats | ||
| totalStreams={totalStreams} | ||
| measuredSongCount={measuredSongCount} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| "use client"; | ||
|
|
||
| import { FormEvent, useState } from "react"; | ||
|
|
||
| interface CatalogReportEmailCaptureProps { | ||
| catalogId: string; | ||
| headlineValue?: number; | ||
| } | ||
|
|
||
| /** | ||
| * "Email me this report" capture (chat#1902 item C3). Works for anonymous | ||
| * viewers: the route it posts to is unauthenticated, and the valuation stays | ||
| * fully visible on the page regardless of what happens here (capture | ||
| * alongside, never in front). Success replaces the form. | ||
| */ | ||
| const CatalogReportEmailCapture = ({ | ||
| catalogId, | ||
| headlineValue, | ||
| }: CatalogReportEmailCaptureProps) => { | ||
| const [email, setEmail] = useState(""); | ||
| const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">( | ||
| "idle", | ||
| ); | ||
|
|
||
| const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { | ||
| 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 ( | ||
| <section | ||
| aria-label="Email me this report" | ||
| className="rounded-2xl bg-card p-6 sm:p-8 shadow-[0_0_0_1px_var(--border),0_2px_4px_rgba(0,0,0,0.04)]" | ||
| > | ||
| {status === "sent" ? ( | ||
| <p className="font-heading text-sm font-bold text-foreground"> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Screen-reader users may not be told that the report was sent because the asynchronously rendered success message has no status/live-region semantics. Adding Prompt for AI agents |
||
| Report sent. Check your inbox. | ||
| </p> | ||
| ) : ( | ||
| <> | ||
| <h2 className="font-heading text-sm font-bold text-foreground"> | ||
| Email me this report | ||
| </h2> | ||
| <p className="mt-1.5 text-sm leading-relaxed text-muted-foreground"> | ||
| Get the link and the headline number in your inbox so you can come | ||
| back to it anytime. | ||
| </p> | ||
| <form | ||
| onSubmit={handleSubmit} | ||
| className="mt-4 flex flex-col gap-2 sm:flex-row" | ||
| > | ||
| <input | ||
| type="email" | ||
| required | ||
| value={email} | ||
| onChange={(event) => 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" | ||
| /> | ||
| <button | ||
| type="submit" | ||
| disabled={status === "sending"} | ||
| className="inline-flex shrink-0 items-center justify-center rounded-xl bg-primary px-5 py-2.5 font-heading text-sm font-semibold text-primary-foreground transition-colors duration-200 hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" | ||
| > | ||
| {status === "sending" ? "Sending..." : "Send it"} | ||
| </button> | ||
| </form> | ||
| {status === "error" && ( | ||
| <p role="alert" className="mt-2 text-sm text-muted-foreground"> | ||
| Could not send the email. Check the address and try again. | ||
| </p> | ||
| )} | ||
| </> | ||
| )} | ||
| </section> | ||
| ); | ||
| }; | ||
|
|
||
| export default CatalogReportEmailCapture; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(/[–—]/); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
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.
P1: This unauthenticated POST endpoint triggers a paid Resend API call on every successful request but has no rate limiting, IP throttling, or abuse prevention. An attacker could script thousands of email-send requests per minute from different IPs, incurring cost exposure and potentially using this as a spam relay. Consider adding at minimum an IP-based rate limiter (e.g., in-memory with a modest per-IP window, or via Vercel's edge middleware) before calling the send function. Since the PR's design deliberately keeps the endpoint unauthenticated for anonymous viewers, rate limiting is the primary defense against abuse.
Prompt for AI agents