Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});
52 changes: 52 additions & 0 deletions app/api/catalogs/[catalogId]/email-report/route.ts
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(

Copy link
Copy Markdown

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
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/catalogs/[catalogId]/email-report/route.ts, line 16:

<comment>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.</comment>

<file context>
@@ -0,0 +1,52 @@
+ * 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 }> },
</file context>

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive the headline value server-side

This endpoint trusts headline_value from the request and forwards it into the email subject/body, so anyone can POST an arbitrary number for any catalog ID and send a report email showing a valuation that was never computed for that catalog. Since this is an unauthenticated route, the server should either fetch/compute the headline from catalog data or omit it rather than accepting the client-supplied value.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/catalogs/[catalogId]/email-report/route.ts, line 40:

<comment>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.</comment>

<file context>
@@ -0,0 +1,52 @@
+  const sent = await sendCatalogReportEmail({
+    email: validated.data.email,
+    catalogId,
+    headlineValue: validated.data.headline_value,
+  });
+  if (!sent) {
</file context>

});
if (!sent) {
return NextResponse.json(
{ error: "Failed to send the report email" },
{ status: 502 },
);
}

return NextResponse.json({ success: true });
}

export const dynamic = "force-dynamic";
14 changes: 13 additions & 1 deletion components/Catalog/report/CatalogReportContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't offer anonymous users a link they still cannot view

When the viewer is anonymous, useCatalogMeasurements is disabled because it requires authenticated, so measurements stays undefined and this new fallback renders the email capture anyway. Submitting it sends only /catalogs/${catalogId} back to the user, so the recipient returns to the same auth-gated error state rather than seeing the report; this breaks the advertised anonymous "Email me this report" flow unless the report data is made public or the capture is hidden until a report can actually load.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 measurements is undefined purely because the measurements query requires auth. Submitting the form here just emails the same /catalogs/${catalogId} link, so the recipient lands back on the same auth-gated error state instead of seeing a report. This undermines the anonymous "Email me this report" flow unless the report data can be made public for this state or the capture is hidden until a report can actually load.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At components/Catalog/report/CatalogReportContent.tsx, line 55:

<comment>In the no-measurements branch, the email capture is now rendered even when the viewer is anonymous and `measurements` is undefined purely because the measurements query requires auth. Submitting the form here just emails the same `/catalogs/${catalogId}` link, so the recipient lands back on the same auth-gated error state instead of seeing a report. This undermines the anonymous "Email me this report" flow unless the report data can be made public for this state or the capture is hidden until a report can actually load.</comment>

<file context>
@@ -46,7 +47,14 @@ const CatalogReportContent = ({ catalogId }: CatalogReportContentProps) => {
+    return (
+      <div className="flex flex-col gap-4 max-w-3xl">
+        <CatalogReportError error={measurementsQuery.error} />
+        <CatalogReportEmailCapture catalogId={catalogId} />
+      </div>
+    );
</file context>

</div>
);
}

const totalStreams =
Expand All @@ -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}
Expand Down
93 changes: 93 additions & 0 deletions components/Catalog/report/CatalogReportEmailCapture.tsx
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">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 role="status" (or aria-live="polite") to the success message would announce the result.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At components/Catalog/report/CatalogReportEmailCapture.tsx, line 49:

<comment>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 `role="status"` (or `aria-live="polite"`) to the success message would announce the result.</comment>

<file context>
@@ -0,0 +1,93 @@
+      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">
+          Report sent. Check your inbox.
+        </p>
</file context>

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;
33 changes: 33 additions & 0 deletions lib/catalog/emailReport/__tests__/renderReportEmailHtml.test.ts
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);
});
});
Loading
Loading