From 89fd5f3ade57edadae9671bb86c8e33c966d5913 Mon Sep 17 00:00:00 2001
From: adilallo <39313955+adilallo@users.noreply.github.com>
Date: Fri, 8 May 2026 21:38:18 -0600
Subject: [PATCH 1/6] feat(create): wizard uploads render as images in display
and exports (imageUrl/fileUrl)
---
.../type/CommunityRule/CommunityRule.types.ts | 5 +++
.../type/TextBlock/TextBlock.view.tsx | 42 ++++++++++++++++---
lib/create/documentEntryGuards.ts | 5 ++-
lib/create/ruleExport.ts | 39 +++++++++++++++--
.../ruleSectionsFromMethodSelections.ts | 28 ++++++++++++-
tests/components/TextBlock.test.tsx | 24 ++++++++++-
...publishedDocumentToDisplaySections.test.ts | 40 ++++++++++++++++++
7 files changed, 170 insertions(+), 13 deletions(-)
diff --git a/app/components/type/CommunityRule/CommunityRule.types.ts b/app/components/type/CommunityRule/CommunityRule.types.ts
index 0dc9e21..a1724c3 100644
--- a/app/components/type/CommunityRule/CommunityRule.types.ts
+++ b/app/components/type/CommunityRule/CommunityRule.types.ts
@@ -1,7 +1,12 @@
/** Labeled paragraph group (Figma “Text” stacks under Membership / Decision-making, etc.). */
export interface CommunityRuleLabeledBlock {
label: string;
+ /** With {@link imageUrl}, optional caption paragraphs only (not the uploaded file name). */
body: string;
+ /** Image URL (e.g. custom method upload). Rendered as `
` when set. */
+ imageUrl?: string;
+ /** Non-image attachment URL. Rendered as a link when set and {@link imageUrl} is absent. */
+ fileUrl?: string;
}
export interface CommunityRuleEntry {
diff --git a/app/components/type/TextBlock/TextBlock.view.tsx b/app/components/type/TextBlock/TextBlock.view.tsx
index dcc2a65..fb62b5e 100644
--- a/app/components/type/TextBlock/TextBlock.view.tsx
+++ b/app/components/type/TextBlock/TextBlock.view.tsx
@@ -54,12 +54,42 @@ function TextBlockView({
{title}
{hasRows
- ? rows!.map((row, i) => (
-
- ))
+ ? rows!.map((row, i) => {
+ const imageSrc = row.imageUrl?.trim();
+ const fileHref = row.fileUrl?.trim();
+ const caption = row.body.trim();
+ return (
+
+
{row.label}
+ {imageSrc ? (
+ <>
+ {/* eslint-disable-next-line @next/next/no-img-element -- same-origin or absolute upload URL */}
+

0 ? caption : row.label}
+ className="max-h-[240px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
+ />
+ {caption.length > 0 ? (
+
+ ) : null}
+ >
+ ) : fileHref ? (
+
+
+ {caption.length > 0 ? caption : fileHref}
+
+
+ ) : (
+
+ )}
+
+ );
+ })
: body.trim().length > 0 &&
}
diff --git a/lib/create/documentEntryGuards.ts b/lib/create/documentEntryGuards.ts
index b2bef26..38cb3a9 100644
--- a/lib/create/documentEntryGuards.ts
+++ b/lib/create/documentEntryGuards.ts
@@ -6,7 +6,10 @@ import type {
function isLabeledBlock(x: unknown): x is CommunityRuleLabeledBlock {
if (!x || typeof x !== "object") return false;
const o = x as Record;
- return typeof o.label === "string" && typeof o.body === "string";
+ if (typeof o.label !== "string" || typeof o.body !== "string") return false;
+ if (o.imageUrl !== undefined && typeof o.imageUrl !== "string") return false;
+ if (o.fileUrl !== undefined && typeof o.fileUrl !== "string") return false;
+ return true;
}
/** Shared by publish payload parsing and template body parsing — keep in sync. */
diff --git a/lib/create/ruleExport.ts b/lib/create/ruleExport.ts
index 7319c13..92d90dd 100644
--- a/lib/create/ruleExport.ts
+++ b/lib/create/ruleExport.ts
@@ -27,7 +27,19 @@ function entryToMarkdown(entry: CommunityRuleEntry): string {
const lines: string[] = [`### ${entry.title}`, ""];
if (entry.blocks && entry.blocks.length > 0) {
for (const b of entry.blocks) {
- lines.push(`#### ${b.label}`, "", b.body, "");
+ lines.push(`#### ${b.label}`, "");
+ const img = b.imageUrl?.trim();
+ const file = b.fileUrl?.trim();
+ if (img) {
+ lines.push(
+ ``,
+ "",
+ );
+ } else if (file) {
+ lines.push(`[${b.body.trim() || "file"}](${file})`, "");
+ } else {
+ lines.push(b.body, "");
+ }
}
} else {
const body = (entry.body ?? "").trim();
@@ -86,7 +98,17 @@ export function sectionsToCsv(
for (const ent of sec.entries) {
if (ent.blocks && ent.blocks.length > 0) {
for (const b of ent.blocks) {
- rows.push([sec.categoryName, ent.title, b.label, b.body]);
+ const img = b.imageUrl?.trim();
+ const file = b.fileUrl?.trim();
+ const content =
+ img != null && img.length > 0
+ ? b.body.trim().length > 0
+ ? `${b.body}\n${img}`
+ : img
+ : file != null && file.length > 0
+ ? `${b.body}\n${file}`
+ : b.body;
+ rows.push([sec.categoryName, ent.title, b.label, content]);
}
} else {
rows.push([sec.categoryName, ent.title, "", ent.body ?? ""]);
@@ -136,7 +158,18 @@ function entryToPrintHtml(entry: CommunityRuleEntry): string {
if (entry.blocks && entry.blocks.length > 0) {
for (const b of entry.blocks) {
inner += `${escapeHtml(b.label)}
`;
- inner += paragraphsHtml(b.body);
+ const img = b.imageUrl?.trim();
+ const file = b.fileUrl?.trim();
+ if (img) {
+ inner += `})
`;
+ if (b.body.trim().length > 0) {
+ inner += paragraphsHtml(b.body);
+ }
+ } else if (file) {
+ inner += `${escapeHtml(b.body.trim() || file)}
`;
+ } else {
+ inner += paragraphsHtml(b.body);
+ }
}
} else {
inner += paragraphsHtml(entry.body ?? "");
diff --git a/lib/create/ruleSectionsFromMethodSelections.ts b/lib/create/ruleSectionsFromMethodSelections.ts
index ed05f4b..44600a2 100644
--- a/lib/create/ruleSectionsFromMethodSelections.ts
+++ b/lib/create/ruleSectionsFromMethodSelections.ts
@@ -7,6 +7,15 @@ import type { PublishedMethodSelections } from "./buildPublishPayload";
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
import { templateCategoryToGroupKey } from "./templateReviewMapping";
+/** Uses filename extension and/or URL path so uploads render as `
` vs file link on read-only surfaces. */
+export function wizardUploadDisplaysAsImage(
+ fileName: string | null,
+ assetUrl: string | null,
+): boolean {
+ if (fileName && /\.(jpe?g|png|gif|webp)$/i.test(fileName)) return true;
+ if (assetUrl && /\.(jpe?g|png|gif|webp)(\?|#|$)/i.test(assetUrl)) return true;
+ return false;
+}
/**
* Serialize wizard-authored field blocks into Community Rule labeled rows for
* read-only surfaces (completed step, exported views). Matches how those blocks
@@ -32,8 +41,23 @@ export function labeledBlocksFromCustomMethodCardFieldBlocks(
case "upload": {
const name = nonEmptyTrimmed(b.fileName);
const url = nonEmptyTrimmed(b.assetUrl);
- const body = name ?? url;
- if (body) out.push({ label: b.blockTitle, body });
+ if (url) {
+ if (wizardUploadDisplaysAsImage(name, url)) {
+ out.push({
+ label: b.blockTitle,
+ body: "",
+ imageUrl: url,
+ });
+ } else {
+ out.push({
+ label: b.blockTitle,
+ body: name ?? url,
+ fileUrl: url,
+ });
+ }
+ } else if (name) {
+ out.push({ label: b.blockTitle, body: name });
+ }
break;
}
case "proportion":
diff --git a/tests/components/TextBlock.test.tsx b/tests/components/TextBlock.test.tsx
index d1124a6..fa52913 100644
--- a/tests/components/TextBlock.test.tsx
+++ b/tests/components/TextBlock.test.tsx
@@ -1,9 +1,11 @@
-import { describe } from "vitest";
+import { describe, it, expect } from "vitest";
import {
componentTestSuite,
type ComponentTestSuiteConfig,
} from "../utils/componentTestSuite";
import TextBlock from "../../app/components/type/TextBlock";
+import { screen } from "@testing-library/react";
+import { renderWithProviders as render } from "../utils/test-utils";
type Props = React.ComponentProps;
@@ -23,4 +25,24 @@ const config: ComponentTestSuiteConfig = {
describe("TextBlock", () => {
componentTestSuite(config);
+
+ it("renders labeled row imageUrl as img", () => {
+ render(
+ ,
+ );
+ const img = screen.getByRole("img", { name: "Photo" });
+ expect(img).toHaveAttribute(
+ "src",
+ "/api/uploads/aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee",
+ );
+ });
});
diff --git a/tests/unit/publishedDocumentToDisplaySections.test.ts b/tests/unit/publishedDocumentToDisplaySections.test.ts
index e258581..dd9dc28 100644
--- a/tests/unit/publishedDocumentToDisplaySections.test.ts
+++ b/tests/unit/publishedDocumentToDisplaySections.test.ts
@@ -208,4 +208,44 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
{ label: "Expectations", body: "Answer stored only on field blocks." },
]);
});
+
+ it("exposes custom upload image blocks with imageUrl for CommunityRule display", () => {
+ const customId = "b7c0a9f3-0000-4000-8000-000000000002";
+ const doc = {
+ sections: [],
+ methodSelections: {
+ communication: [
+ {
+ id: customId,
+ label: "Policy with photo",
+ sections: {
+ corePrinciple: "",
+ logisticsAdmin: "",
+ codeOfConduct: "",
+ },
+ },
+ ],
+ },
+ customMethodCardFieldBlocksById: {
+ [customId]: [
+ {
+ kind: "upload" as const,
+ id: "u1",
+ blockTitle: "Site photo",
+ fileName: "garden.jpg",
+ assetUrl: "/api/uploads/11111111-1111-4111-8111-111111111111",
+ },
+ ],
+ },
+ };
+ const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
+ const comm = out.find((s) => s.categoryName === "Communication");
+ expect(comm?.entries[0]?.blocks).toEqual([
+ {
+ label: "Site photo",
+ body: "",
+ imageUrl: "/api/uploads/11111111-1111-4111-8111-111111111111",
+ },
+ ]);
+ });
});
--
2.43.0
From 4595e2648a95a16fe1cd0ca5ce654248e18a884f Mon Sep 17 00:00:00 2001
From: adilallo <39313955+adilallo@users.noreply.github.com>
Date: Fri, 8 May 2026 21:40:43 -0600
Subject: [PATCH 2/6] refactor(create): DRY rule export media + TextBlock row
view
---
.../type/TextBlock/TextBlock.view.tsx | 78 ++++++++++---------
lib/create/ruleExport.ts | 52 +++++++------
.../ruleSectionsFromMethodSelections.ts | 1 +
tests/unit/lib/ruleExport.test.ts | 39 ++++++++++
4 files changed, 112 insertions(+), 58 deletions(-)
diff --git a/app/components/type/TextBlock/TextBlock.view.tsx b/app/components/type/TextBlock/TextBlock.view.tsx
index fb62b5e..b752e19 100644
--- a/app/components/type/TextBlock/TextBlock.view.tsx
+++ b/app/components/type/TextBlock/TextBlock.view.tsx
@@ -3,6 +3,8 @@
import { memo } from "react";
import type { TextBlockProps } from "./TextBlock.types";
+type TextRow = NonNullable[number];
+
/**
* Figma: Utility / **Community Rule / Text Block** (22001:29793).
* Title + body paragraphs and/or labeled rows (12px between stacks, 8px label→body).
@@ -38,6 +40,45 @@ function ParagraphGroup({ text }: { text: string }) {
);
}
+function LabeledRowView({ row }: { row: TextRow }) {
+ const imageSrc = row.imageUrl?.trim();
+ const fileHref = row.fileUrl?.trim();
+ const caption = row.body.trim();
+ const hasCaption = caption.length > 0;
+ const alt = hasCaption ? caption : row.label;
+ const linkText = hasCaption ? caption : fileHref;
+
+ return (
+
+
{row.label}
+ {imageSrc ? (
+ <>
+ {/* eslint-disable-next-line @next/next/no-img-element -- same-origin or absolute upload URL */}
+

+ {hasCaption ?
: null}
+ >
+ ) : fileHref ? (
+
+
+ {linkText}
+
+
+ ) : (
+
+ )}
+
+ );
+}
+
function TextBlockView({
title,
body = "",
@@ -54,42 +95,7 @@ function TextBlockView({
{title}
{hasRows
- ? rows!.map((row, i) => {
- const imageSrc = row.imageUrl?.trim();
- const fileHref = row.fileUrl?.trim();
- const caption = row.body.trim();
- return (
-
-
{row.label}
- {imageSrc ? (
- <>
- {/* eslint-disable-next-line @next/next/no-img-element -- same-origin or absolute upload URL */}
-

0 ? caption : row.label}
- className="max-h-[240px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
- />
- {caption.length > 0 ? (
-
- ) : null}
- >
- ) : fileHref ? (
-
-
- {caption.length > 0 ? caption : fileHref}
-
-
- ) : (
-
- )}
-
- );
- })
+ ? rows!.map((row, i) =>
)
: body.trim().length > 0 &&
}
diff --git a/lib/create/ruleExport.ts b/lib/create/ruleExport.ts
index 92d90dd..359dbb8 100644
--- a/lib/create/ruleExport.ts
+++ b/lib/create/ruleExport.ts
@@ -2,6 +2,7 @@ import { jsPDF } from "jspdf";
import type {
CommunityRuleEntry,
+ CommunityRuleLabeledBlock,
CommunityRuleSection,
} from "../../app/components/type/CommunityRule/CommunityRule.types";
import type { StoredLastPublishedRule } from "./lastPublishedRule";
@@ -23,20 +24,30 @@ export function exportFilenameBase(rule: StoredLastPublishedRule): string {
return fromTitle.length > 0 ? fromTitle : `rule-${rule.id.slice(0, 8)}`;
}
+function labeledBlockMedia(b: CommunityRuleLabeledBlock): {
+ img?: string;
+ file?: string;
+ bodyTrim: string;
+} {
+ const imgRaw = b.imageUrl?.trim();
+ const fileRaw = b.fileUrl?.trim();
+ return {
+ img: imgRaw && imgRaw.length > 0 ? imgRaw : undefined,
+ file: fileRaw && fileRaw.length > 0 ? fileRaw : undefined,
+ bodyTrim: b.body.trim(),
+ };
+}
+
function entryToMarkdown(entry: CommunityRuleEntry): string {
const lines: string[] = [`### ${entry.title}`, ""];
if (entry.blocks && entry.blocks.length > 0) {
for (const b of entry.blocks) {
lines.push(`#### ${b.label}`, "");
- const img = b.imageUrl?.trim();
- const file = b.fileUrl?.trim();
+ const { img, file, bodyTrim } = labeledBlockMedia(b);
if (img) {
- lines.push(
- ``,
- "",
- );
+ lines.push(``, "");
} else if (file) {
- lines.push(`[${b.body.trim() || "file"}](${file})`, "");
+ lines.push(`[${bodyTrim || "file"}](${file})`, "");
} else {
lines.push(b.body, "");
}
@@ -98,16 +109,14 @@ export function sectionsToCsv(
for (const ent of sec.entries) {
if (ent.blocks && ent.blocks.length > 0) {
for (const b of ent.blocks) {
- const img = b.imageUrl?.trim();
- const file = b.fileUrl?.trim();
- const content =
- img != null && img.length > 0
- ? b.body.trim().length > 0
- ? `${b.body}\n${img}`
- : img
- : file != null && file.length > 0
- ? `${b.body}\n${file}`
- : b.body;
+ const { img, file, bodyTrim } = labeledBlockMedia(b);
+ const content = img
+ ? bodyTrim.length > 0
+ ? `${b.body}\n${img}`
+ : img
+ : file
+ ? `${b.body}\n${file}`
+ : b.body;
rows.push([sec.categoryName, ent.title, b.label, content]);
}
} else {
@@ -158,15 +167,14 @@ function entryToPrintHtml(entry: CommunityRuleEntry): string {
if (entry.blocks && entry.blocks.length > 0) {
for (const b of entry.blocks) {
inner += `${escapeHtml(b.label)}
`;
- const img = b.imageUrl?.trim();
- const file = b.fileUrl?.trim();
+ const { img, file, bodyTrim } = labeledBlockMedia(b);
if (img) {
- inner += `})
`;
- if (b.body.trim().length > 0) {
+ inner += `})
`;
+ if (bodyTrim.length > 0) {
inner += paragraphsHtml(b.body);
}
} else if (file) {
- inner += `${escapeHtml(b.body.trim() || file)}
`;
+ inner += `${escapeHtml(bodyTrim || file)}
`;
} else {
inner += paragraphsHtml(b.body);
}
diff --git a/lib/create/ruleSectionsFromMethodSelections.ts b/lib/create/ruleSectionsFromMethodSelections.ts
index 44600a2..f64ede8 100644
--- a/lib/create/ruleSectionsFromMethodSelections.ts
+++ b/lib/create/ruleSectionsFromMethodSelections.ts
@@ -16,6 +16,7 @@ export function wizardUploadDisplaysAsImage(
if (assetUrl && /\.(jpe?g|png|gif|webp)(\?|#|$)/i.test(assetUrl)) return true;
return false;
}
+
/**
* Serialize wizard-authored field blocks into Community Rule labeled rows for
* read-only surfaces (completed step, exported views). Matches how those blocks
diff --git a/tests/unit/lib/ruleExport.test.ts b/tests/unit/lib/ruleExport.test.ts
index 51aa338..77f6315 100644
--- a/tests/unit/lib/ruleExport.test.ts
+++ b/tests/unit/lib/ruleExport.test.ts
@@ -95,6 +95,45 @@ describe("ruleExport", () => {
expect(csv).toContain(",Title,,My Rule");
});
+ it("sectionsToMarkdown, sectionsToCsv, and printable HTML include imageUrl and fileUrl blocks", () => {
+ const sections: CommunityRuleSection[] = [
+ {
+ categoryName: "Values",
+ entries: [
+ {
+ title: "Entry",
+ body: "",
+ blocks: [
+ {
+ label: "Photo",
+ body: "Caption",
+ imageUrl: "https://cdn.example.com/pic.jpg",
+ },
+ {
+ label: "Handbook",
+ body: "Download",
+ fileUrl: "https://cdn.example.com/guidance.pdf",
+ },
+ ],
+ },
+ ],
+ },
+ ];
+ const md = sectionsToMarkdown("Rule", null, sections);
+ expect(md).toContain("");
+ expect(md).toContain("[Download](https://cdn.example.com/guidance.pdf)");
+
+ const csv = sectionsToCsv("Rule", null, sections);
+ expect(csv).toContain("Caption\nhttps://cdn.example.com/pic.jpg");
+ expect(csv).toContain("Download\nhttps://cdn.example.com/guidance.pdf");
+
+ const html = buildPrintableRuleHtmlDocument("Rule", null, sections);
+ expect(html).toContain('src="https://cdn.example.com/pic.jpg"');
+ expect(html).toContain("Caption");
+ expect(html).toContain('href="https://cdn.example.com/guidance.pdf"');
+ expect(html).toContain("Download");
+ });
+
it("buildPrintableRuleHtmlDocument escapes HTML in user content", () => {
const sections: CommunityRuleSection[] = [
{
--
2.43.0
From 534c6c7c0ed2972cb3a6cd140b087b6b39821ebe Mon Sep 17 00:00:00 2001
From: adilallo <39313955+adilallo@users.noreply.github.com>
Date: Fri, 8 May 2026 21:51:55 -0600
Subject: [PATCH 3/6] Create flow cleanup leftover
---
lib/create/ruleExport.ts | 48 +++++++++++++++++++++++--------
tests/unit/lib/ruleExport.test.ts | 32 +++++++++++++++++++++
2 files changed, 68 insertions(+), 12 deletions(-)
diff --git a/lib/create/ruleExport.ts b/lib/create/ruleExport.ts
index 359dbb8..f766676 100644
--- a/lib/create/ruleExport.ts
+++ b/lib/create/ruleExport.ts
@@ -112,10 +112,12 @@ export function sectionsToCsv(
const { img, file, bodyTrim } = labeledBlockMedia(b);
const content = img
? bodyTrim.length > 0
- ? `${b.body}\n${img}`
+ ? `${bodyTrim}\n${img}`
: img
: file
- ? `${b.body}\n${file}`
+ ? bodyTrim.length > 0
+ ? `${bodyTrim}\n${file}`
+ : file
: b.body;
rows.push([sec.categoryName, ent.title, b.label, content]);
}
@@ -235,6 +237,29 @@ export function sectionsToPdfBlob(
}
}
+ function writeBlockBodyParagraphs(body: string): void {
+ for (const p of splitDisplayParagraphs(body)) {
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(11);
+ const lines = doc.splitTextToSize(p, maxW);
+ const dim = doc.getTextDimensions(lines.join("\n"), { maxWidth: maxW });
+ ensureSpace(dim.h + 1);
+ doc.text(lines, margin, y);
+ y += dim.h + 2;
+ }
+ }
+
+ /** Plain URL line(s); italic for images so captions vs URL read distinctly in print. */
+ function writeMediaUrlLines(url: string, fontStyle: "normal" | "italic"): void {
+ doc.setFont("helvetica", fontStyle);
+ doc.setFontSize(10);
+ const urlLines = doc.splitTextToSize(url, maxW);
+ const urlDim = doc.getTextDimensions(urlLines.join("\n"), { maxWidth: maxW });
+ ensureSpace(urlDim.h + 1);
+ doc.text(urlLines, margin, y);
+ y += urlDim.h + 2;
+ }
+
doc.setFont("helvetica", "bold");
doc.setFontSize(16);
{
@@ -294,16 +319,15 @@ export function sectionsToPdfBlob(
doc.text(lines, margin, y);
y += dim.h + 2;
}
- for (const p of splitDisplayParagraphs(b.body)) {
- doc.setFont("helvetica", "normal");
- doc.setFontSize(11);
- const lines = doc.splitTextToSize(p, maxW);
- const dim = doc.getTextDimensions(lines.join("\n"), {
- maxWidth: maxW,
- });
- ensureSpace(dim.h + 1);
- doc.text(lines, margin, y);
- y += dim.h + 2;
+ const { img, file } = labeledBlockMedia(b);
+ if (img) {
+ writeBlockBodyParagraphs(b.body);
+ writeMediaUrlLines(img, "italic");
+ } else if (file) {
+ writeBlockBodyParagraphs(b.body);
+ writeMediaUrlLines(file, "normal");
+ } else {
+ writeBlockBodyParagraphs(b.body);
}
}
} else {
diff --git a/tests/unit/lib/ruleExport.test.ts b/tests/unit/lib/ruleExport.test.ts
index 77f6315..1c412d6 100644
--- a/tests/unit/lib/ruleExport.test.ts
+++ b/tests/unit/lib/ruleExport.test.ts
@@ -6,6 +6,7 @@ import {
exportFilenameBase,
sectionsToCsv,
sectionsToMarkdown,
+ sectionsToPdfBlob,
} from "../../../lib/create/ruleExport";
import type { CommunityRuleSection } from "../../../app/components/type/CommunityRule/CommunityRule.types";
@@ -134,6 +135,37 @@ describe("ruleExport", () => {
expect(html).toContain("Download");
});
+ it("sectionsToPdfBlob embeds image and file URLs as text for labeled blocks", async () => {
+ const sections: CommunityRuleSection[] = [
+ {
+ categoryName: "Values",
+ entries: [
+ {
+ title: "Entry",
+ body: "",
+ blocks: [
+ {
+ label: "Photo",
+ body: "Caption",
+ imageUrl: "https://cdn.example.com/pic.jpg",
+ },
+ {
+ label: "Handbook",
+ body: "Download",
+ fileUrl: "https://cdn.example.com/guidance.pdf",
+ },
+ ],
+ },
+ ],
+ },
+ ];
+ const blob = sectionsToPdfBlob("Rule", null, sections);
+ const buf = new Uint8Array(await readBlobAsArrayBuffer(blob));
+ const raw = new TextDecoder("latin1").decode(buf);
+ expect(raw).toContain("https://cdn.example.com/pic.jpg");
+ expect(raw).toContain("https://cdn.example.com/guidance.pdf");
+ });
+
it("buildPrintableRuleHtmlDocument escapes HTML in user content", () => {
const sections: CommunityRuleSection[] = [
{
--
2.43.0
From 9f2141a62d9edc949484058a304dbf35e4d0b7cc Mon Sep 17 00:00:00 2001
From: adilallo <39313955+adilallo@users.noreply.github.com>
Date: Sat, 9 May 2026 23:07:59 -0600
Subject: [PATCH 4/6] Manage stakeholders implemented
---
app/(app)/create/CreateFlowLayoutClient.tsx | 50 ++++
.../create/hooks/useCreateFlowFinalize.ts | 4 +
.../select/ConfirmStakeholdersScreen.tsx | 122 +++++++++-
.../PublishedStakeholdersManagePanel.tsx | 218 ++++++++++++++++++
app/(app)/create/types.ts | 7 +-
app/(app)/create/utils/createFlowPaths.ts | 8 +-
app/(app)/create/utils/flowSteps.ts | 7 +
.../profile/_components/ProfilePage.view.tsx | 54 +++--
.../invites/rule-stakeholder/verify/route.ts | 122 ++++++++++
.../[stakeholderId]/resend/route.ts | 120 ++++++++++
.../stakeholders/[stakeholderId]/route.ts | 48 ++++
app/api/rules/[id]/stakeholders/route.ts | 192 +++++++++++++++
app/api/rules/me/route.ts | 15 +-
app/api/rules/route.ts | 178 +++++++++++---
app/components/modals/Login/LoginForm.tsx | 12 +-
.../CreateFlowTopNav.container.tsx | 4 +
.../CreateFlowTopNav.types.ts | 10 +
.../CreateFlowTopNav.view.tsx | 16 ++
docs/create-flow.md | 4 +
docs/guides/backend-linear-tickets.md | 2 +-
lib/create/api.ts | 184 ++++++++++++++-
lib/create/stakeholderLimits.ts | 5 +
lib/server/mail.ts | 29 +++
lib/server/publishedRules.ts | 65 ++++++
lib/server/responses.ts | 7 +-
lib/server/ruleStakeholderInviteOps.ts | 55 +++++
lib/server/ruleStakeholders.ts | 2 +
lib/server/validation/createFlowSchemas.ts | 45 +++-
.../confirmStakeholders.json | 27 ++-
messages/en/create/topNav.json | 2 +
messages/en/pages/login.json | 3 +-
.../migration.sql | 35 +++
prisma/schema.prisma | 30 +++
.../navigation/CreateFlowTopNav.stories.js | 18 +-
.../ConfirmStakeholdersPage.test.tsx | 2 +-
tests/components/CreateFlowTopNav.test.tsx | 29 +++
tests/unit/createFlowPaths.test.ts | 9 +-
tests/unit/createFlowValidation.test.ts | 34 +++
.../unit/hooks/useCreateFlowFinalize.test.tsx | 31 +++
.../ruleStakeholderInviteVerifyRoute.test.ts | 100 ++++++++
tests/unit/rulesMeRoute.test.ts | 17 +-
tests/unit/rulesPublishPostRoute.test.ts | 174 ++++++++++++++
tests/unit/rulesStakeholdersRoutes.test.ts | 79 +++++++
43 files changed, 2082 insertions(+), 93 deletions(-)
create mode 100644 app/(app)/create/screens/select/PublishedStakeholdersManagePanel.tsx
create mode 100644 app/api/invites/rule-stakeholder/verify/route.ts
create mode 100644 app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts
create mode 100644 app/api/rules/[id]/stakeholders/[stakeholderId]/route.ts
create mode 100644 app/api/rules/[id]/stakeholders/route.ts
create mode 100644 lib/create/stakeholderLimits.ts
create mode 100644 lib/server/ruleStakeholderInviteOps.ts
create mode 100644 lib/server/ruleStakeholders.ts
create mode 100644 prisma/migrations/20260509035322_add_rule_stakeholder/migration.sql
create mode 100644 tests/unit/ruleStakeholderInviteVerifyRoute.test.ts
create mode 100644 tests/unit/rulesPublishPostRoute.test.ts
create mode 100644 tests/unit/rulesStakeholdersRoutes.test.ts
diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx
index 642198f..1fe7479 100644
--- a/app/(app)/create/CreateFlowLayoutClient.tsx
+++ b/app/(app)/create/CreateFlowLayoutClient.tsx
@@ -17,6 +17,9 @@ import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav";
import {
+ CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
+ CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
+ CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
getNextStep,
getStepIndex,
parseReviewReturnSearchParam,
@@ -158,7 +161,17 @@ function CreateFlowLayoutContent({
resetCustomRuleSelections,
setMethodSectionsPinCommitted,
replaceState,
+ markCreateFlowInteraction,
} = useCreateFlow();
+ const manageStakeholdersIntent =
+ searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) ===
+ CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE;
+ const editingPublishedRuleIdTrimmed =
+ state.editingPublishedRuleId?.trim() ?? "";
+ const isConfirmStakeholdersManagePublished =
+ currentStep === "confirm-stakeholders" &&
+ manageStakeholdersIntent &&
+ editingPublishedRuleIdTrimmed.length > 0;
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
useCreateFlowDraftSaveBanner();
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
@@ -411,6 +424,7 @@ function CreateFlowLayoutContent({
const isRightRailStep = currentStep === "decision-approaches";
const isFinalReviewLike =
currentStep === "final-review" || currentStep === "edit-rule";
+ const isEditRuleStep = currentStep === "edit-rule";
const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep);
/** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
@@ -581,6 +595,7 @@ function CreateFlowLayoutContent({
hasShare={isCompletedStep}
hasExport={isCompletedStep}
hasEdit={isCompletedStep}
+ hasManageStakeholders={isEditRuleStep}
saveDraftOnExit={saveDraftOnExit}
onShare={
isCompletedStep ? () => void handleOpenCompletedShareModal() : undefined
@@ -601,6 +616,20 @@ function CreateFlowLayoutContent({
}
: undefined
}
+ onManageStakeholders={
+ isEditRuleStep
+ ? () => {
+ markCreateFlowInteraction();
+ router.push(
+ createFlowStepPath("confirm-stakeholders", {
+ [CREATE_FLOW_REVIEW_RETURN_QUERY_KEY]: "edit-rule",
+ [CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY]:
+ CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
+ }),
+ );
+ }
+ : undefined
+ }
onExit={(opts) => void handleExit(opts)}
buttonPalette={isCompletedStep ? "inverse" : undefined}
className={`shrink-0 ${
@@ -762,6 +791,27 @@ function CreateFlowLayoutContent({
>
{footer[customRuleConfirmFooter.footerMessageKey]}
+ ) : isConfirmStakeholdersManagePublished ? (
+
) : nextStep || isFinalReviewLike ? (
)}
+ {hasManageStakeholders && onManageStakeholders ? (
+
+ {t("manageStakeholders")}
+
+ ) : null}
+
` and only wires `onChipClick`. `category.onAddClick` is **not provided**, so the `+` button on each MultiSelect category renders by default (`addButton={!hideCategoryAddButton}` in [`Rule.view.tsx`](../../app/components/cards/Rule/Rule.view.tsx)) but **does nothing** when clicked. Dead control we are shipping today.
diff --git a/lib/create/api.ts b/lib/create/api.ts
index b0e88cb..2fa21c9 100644
--- a/lib/create/api.ts
+++ b/lib/create/api.ts
@@ -199,6 +199,7 @@ export async function publishRule(input: {
title: string;
summary?: string;
document: Record;
+ stakeholderEmails?: string[];
}): Promise<
| { ok: true; id: string; title: string }
| { ok: false; error: string; status?: number }
@@ -212,6 +213,9 @@ export async function publishRule(input: {
title: input.title,
summary: input.summary,
document: input.document,
+ ...(input.stakeholderEmails?.length
+ ? { stakeholderEmails: input.stakeholderEmails }
+ : {}),
}),
});
const data = (await safeParseJsonResponse(res)) as {
@@ -289,6 +293,8 @@ export type MyPublishedRule = {
summary: string | null;
createdAt: string;
updatedAt: string;
+ /** `owner` = authored rule; `stakeholder` = accepted invite (view only). */
+ role: "owner" | "stakeholder";
};
/**
@@ -306,7 +312,16 @@ export async function fetchMyPublishedRules(): Promise<
rules?: MyPublishedRule[];
} | null;
if (!data || !Array.isArray(data.rules)) return null;
- return data.rules;
+ const rules = data.rules.filter(
+ (r): r is MyPublishedRule =>
+ r != null &&
+ typeof r === "object" &&
+ typeof (r as MyPublishedRule).id === "string" &&
+ typeof (r as MyPublishedRule).title === "string" &&
+ ((r as MyPublishedRule).role === "owner" ||
+ (r as MyPublishedRule).role === "stakeholder"),
+ );
+ return rules;
} catch {
return null;
}
@@ -355,6 +370,173 @@ export async function fetchPublishedRuleDetail(
}
}
+export type RuleStakeholderListItem = {
+ id: string;
+ email: string;
+ invitedAt: string;
+ acceptedAt: string | null;
+ status: "pending" | "accepted";
+};
+
+function parseStakeholdersPayload(data: unknown): RuleStakeholderListItem[] | null {
+ if (!data || typeof data !== "object" || !("stakeholders" in data)) {
+ return null;
+ }
+ const raw = (data as { stakeholders: unknown }).stakeholders;
+ if (!Array.isArray(raw)) return null;
+ const out: RuleStakeholderListItem[] = [];
+ for (const x of raw) {
+ if (
+ !x ||
+ typeof x !== "object" ||
+ typeof (x as { id?: unknown }).id !== "string" ||
+ typeof (x as { email?: unknown }).email !== "string" ||
+ typeof (x as { invitedAt?: unknown }).invitedAt !== "string" ||
+ ((x as { status?: unknown }).status !== "pending" &&
+ (x as { status?: unknown }).status !== "accepted")
+ ) {
+ continue;
+ }
+ const acceptedRaw = (x as { acceptedAt?: unknown }).acceptedAt;
+ const acceptedAt =
+ acceptedRaw === null
+ ? null
+ : typeof acceptedRaw === "string"
+ ? acceptedRaw
+ : null;
+ out.push({
+ id: (x as { id: string }).id,
+ email: (x as { email: string }).email,
+ invitedAt: (x as { invitedAt: string }).invitedAt,
+ acceptedAt,
+ status: (x as { status: "pending" | "accepted" }).status,
+ });
+ }
+ return out;
+}
+
+export async function fetchRuleStakeholders(
+ ruleId: string,
+): Promise {
+ try {
+ const res = await fetch(
+ `/api/rules/${encodeURIComponent(ruleId)}/stakeholders`,
+ { credentials: "include" },
+ );
+ if (!res.ok) return null;
+ const data = await safeParseJsonResponse(res);
+ return parseStakeholdersPayload(data);
+ } catch {
+ return null;
+ }
+}
+
+export type RuleStakeholderMutationResult =
+ | { ok: true }
+ | { ok: false; error: string; status: number; retryAfterMs?: number };
+
+function retryAfterFromResponse(
+ res: Response,
+ data: unknown,
+): number | undefined {
+ if (res.status !== 429) return undefined;
+ if (data && typeof data === "object" && "details" in data) {
+ const d = (data as { details?: unknown }).details;
+ if (d && typeof d === "object" && "retryAfterMs" in d) {
+ const ms = (d as { retryAfterMs?: unknown }).retryAfterMs;
+ if (typeof ms === "number" && ms > 0) return ms;
+ }
+ }
+ const h = res.headers.get("retry-after");
+ if (h) {
+ const sec = Number.parseInt(h, 10);
+ if (!Number.isNaN(sec)) return sec * 1000;
+ }
+ return undefined;
+}
+
+export async function addRuleStakeholder(
+ ruleId: string,
+ email: string,
+): Promise {
+ try {
+ const res = await fetch(
+ `/api/rules/${encodeURIComponent(ruleId)}/stakeholders`,
+ {
+ method: "POST",
+ credentials: "include",
+ headers: jsonHeaders,
+ body: JSON.stringify({ email }),
+ },
+ );
+ if (res.ok) return { ok: true };
+ const data = await safeParseJsonResponse(res);
+ return {
+ ok: false as const,
+ error: readApiErrorMessage(data),
+ status: res.status,
+ retryAfterMs: retryAfterFromResponse(res, data),
+ };
+ } catch {
+ return {
+ ok: false as const,
+ error: DRAFT_SAVE_NETWORK_ERROR,
+ status: 0,
+ };
+ }
+}
+
+export async function deleteRuleStakeholder(
+ ruleId: string,
+ stakeholderId: string,
+): Promise {
+ try {
+ const res = await fetch(
+ `/api/rules/${encodeURIComponent(ruleId)}/stakeholders/${encodeURIComponent(stakeholderId)}`,
+ { method: "DELETE", credentials: "include" },
+ );
+ if (res.ok) return { ok: true };
+ const data = await safeParseJsonResponse(res);
+ return {
+ ok: false as const,
+ error: readApiErrorMessage(data),
+ status: res.status,
+ };
+ } catch {
+ return {
+ ok: false as const,
+ error: DRAFT_SAVE_NETWORK_ERROR,
+ status: 0,
+ };
+ }
+}
+
+export async function resendRuleStakeholderInvite(
+ ruleId: string,
+ stakeholderId: string,
+): Promise {
+ try {
+ const res = await fetch(
+ `/api/rules/${encodeURIComponent(ruleId)}/stakeholders/${encodeURIComponent(stakeholderId)}/resend`,
+ { method: "POST", credentials: "include" },
+ );
+ if (res.ok) return { ok: true };
+ const data = await safeParseJsonResponse(res);
+ return {
+ ok: false as const,
+ error: readApiErrorMessage(data),
+ status: res.status,
+ retryAfterMs: retryAfterFromResponse(res, data),
+ };
+ } catch {
+ return {
+ ok: false as const,
+ error: DRAFT_SAVE_NETWORK_ERROR,
+ status: 0,
+ };
+ }
+}
+
export type DeleteRuleResult =
| { ok: true }
| { ok: false; error: string; status: number };
diff --git a/lib/create/stakeholderLimits.ts b/lib/create/stakeholderLimits.ts
new file mode 100644
index 0000000..3447172
--- /dev/null
+++ b/lib/create/stakeholderLimits.ts
@@ -0,0 +1,5 @@
+/**
+ * Max stakeholder emails per draft + publish body.
+ * Server: {@link MAX_STAKEHOLDER_EMAILS} in `lib/server/validation/createFlowSchemas.ts` must match.
+ */
+export const MAX_STAKEHOLDER_EMAILS = 30;
diff --git a/lib/server/mail.ts b/lib/server/mail.ts
index 2af3f32..83ef508 100644
--- a/lib/server/mail.ts
+++ b/lib/server/mail.ts
@@ -27,6 +27,35 @@ export async function sendMagicLinkEmail(
}
/** CR-103: confirm control of the new inbox before `User.email` is updated. */
+/** Stakeholder invite after rule publish (one-time link, same dev/Mailhog pattern as magic link). */
+export async function sendRuleStakeholderInviteEmail(
+ to: string,
+ verifyUrl: string,
+ ruleTitle: string,
+): Promise {
+ const url = process.env.SMTP_URL;
+
+ if (!url) {
+ if (process.env.NODE_ENV === "development") {
+ logger.info(
+ `[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`,
+ );
+ return;
+ }
+ throw new Error("SMTP_URL is not configured");
+ }
+
+ const transporter = nodemailer.createTransport(url);
+ const from = process.env.SMTP_FROM ?? "noreply@localhost";
+
+ await transporter.sendMail({
+ from,
+ to,
+ subject: `You're invited to view a Community Rule: ${ruleTitle}`,
+ text: `You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 15 minutes and works once:\n\n${verifyUrl}\n\nIf you did not expect this, you can ignore this email.`,
+ });
+}
+
export async function sendEmailChangeEmail(
to: string,
verifyUrl: string,
diff --git a/lib/server/publishedRules.ts b/lib/server/publishedRules.ts
index f91e765..6f1ecf0 100644
--- a/lib/server/publishedRules.ts
+++ b/lib/server/publishedRules.ts
@@ -88,3 +88,68 @@ export async function listPublishedRulesForUser(
return null;
}
}
+
+export type ProfileRuleListItem = OwnerPublishedRuleListItem & {
+ role: "owner" | "stakeholder";
+};
+
+/**
+ * Published rules the user can access as an **accepted** stakeholder (`userId` set).
+ * Same metadata shape as {@link listPublishedRulesForUser}; no `document`.
+ */
+export async function listStakeholderRulesForUser(
+ userId: string,
+ take: number,
+): Promise {
+ if (!isDatabaseConfigured()) return null;
+ if (typeof userId !== "string" || userId.trim() === "") return null;
+ const clamped = Math.min(Math.max(0, take), 100);
+ if (clamped === 0) return [];
+ try {
+ const rows = await prisma.ruleStakeholder.findMany({
+ where: { userId },
+ take: clamped,
+ orderBy: [{ rule: { updatedAt: "desc" } }, { id: "asc" }],
+ select: {
+ rule: { select: PUBLISHED_RULE_OWNER_LIST_SELECT },
+ },
+ });
+ return rows.map((r) => r.rule);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Profile list: owned rules plus stakeholder access, **owner wins** if both,
+ * sorted by `updatedAt` desc (then `id`).
+ */
+export async function listProfileRulesForUser(
+ userId: string,
+ take: number,
+): Promise {
+ const cap = Math.min(Math.max(0, take), 100);
+ if (cap === 0) return [];
+ /** Merge then slice so ordering is global by `updatedAt`. */
+ const fetchCap = 100;
+ const [owned, stakeholderRules] = await Promise.all([
+ listPublishedRulesForUser(userId, fetchCap),
+ listStakeholderRulesForUser(userId, fetchCap),
+ ]);
+ if (owned === null || stakeholderRules === null) return null;
+ const ownerIds = new Set(owned.map((r) => r.id));
+ const stakeholderOnly = stakeholderRules.filter((r) => !ownerIds.has(r.id));
+ const combined: ProfileRuleListItem[] = [
+ ...owned.map((r) => ({ ...r, role: "owner" as const })),
+ ...stakeholderOnly.map((r) => ({
+ ...r,
+ role: "stakeholder" as const,
+ })),
+ ];
+ combined.sort((a, b) => {
+ const t = b.updatedAt.getTime() - a.updatedAt.getTime();
+ if (t !== 0) return t;
+ return a.id.localeCompare(b.id);
+ });
+ return combined.slice(0, cap);
+}
diff --git a/lib/server/responses.ts b/lib/server/responses.ts
index 23cf49b..1dcc0e9 100644
--- a/lib/server/responses.ts
+++ b/lib/server/responses.ts
@@ -21,7 +21,8 @@ export type ApiErrorCode =
| "rate_limited"
| "server_misconfigured"
| "mail_failed"
- | "internal_error";
+ | "internal_error"
+ | "conflict";
export interface ApiErrorBody {
error: { code: ApiErrorCode; message: string };
@@ -66,6 +67,10 @@ export function forbidden(message = "Forbidden"): NextResponse {
return errorJson("forbidden", message, 403);
}
+export function conflict(message = "Conflict"): NextResponse {
+ return errorJson("conflict", message, 409);
+}
+
export function rateLimited(retryAfterMs: number): NextResponse {
const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
return errorJson("rate_limited", "Too many requests", 429, {
diff --git a/lib/server/ruleStakeholderInviteOps.ts b/lib/server/ruleStakeholderInviteOps.ts
new file mode 100644
index 0000000..91516da
--- /dev/null
+++ b/lib/server/ruleStakeholderInviteOps.ts
@@ -0,0 +1,55 @@
+import { prisma } from "./db";
+import { hashSessionToken, newSessionToken } from "./hash";
+import { sendRuleStakeholderInviteEmail } from "./mail";
+import { logRouteError } from "./requestId";
+import { STAKEHOLDER_INVITE_TTL_MS } from "./ruleStakeholders";
+
+export function stakeholderInviteVerifyUrl(origin: string, token: string): string {
+ return `${origin}/api/invites/rule-stakeholder/verify?token=${encodeURIComponent(token)}`;
+}
+
+/**
+ * Creates a pending {@link RuleStakeholder} row and sends the invite email.
+ * On mail failure, deletes the row and returns `ok: false`.
+ */
+export async function createRuleStakeholderInviteAndSendMail(opts: {
+ scope: string;
+ requestId: string;
+ origin: string;
+ ruleId: string;
+ ruleTitle: string;
+ email: string;
+ invitedByUserId: string;
+ pepper: string;
+}): Promise<{ ok: true } | { ok: false }> {
+ const token = newSessionToken();
+ const tokenHash = hashSessionToken(token, opts.pepper);
+ const expiresAt = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS);
+
+ const row = await prisma.ruleStakeholder.create({
+ data: {
+ ruleId: opts.ruleId,
+ email: opts.email,
+ invitedByUserId: opts.invitedByUserId,
+ inviteTokenHash: tokenHash,
+ inviteExpiresAt: expiresAt,
+ },
+ });
+
+ const verifyUrl = stakeholderInviteVerifyUrl(opts.origin, token);
+ try {
+ await sendRuleStakeholderInviteEmail(opts.email, verifyUrl, opts.ruleTitle);
+ return { ok: true };
+ } catch (err) {
+ logRouteError(opts.scope, opts.requestId, err, {
+ phase: "sendRuleStakeholderInviteEmail",
+ email: opts.email,
+ });
+ try {
+ await prisma.ruleStakeholder.delete({ where: { id: row.id } });
+ } catch {
+ /* best-effort cleanup */
+ }
+ return { ok: false };
+ }
+}
diff --git a/lib/server/ruleStakeholders.ts b/lib/server/ruleStakeholders.ts
new file mode 100644
index 0000000..f1eee6b
--- /dev/null
+++ b/lib/server/ruleStakeholders.ts
@@ -0,0 +1,2 @@
+/** Parity with magic-link request TTL (15 minutes). */
+export const STAKEHOLDER_INVITE_TTL_MS = 15 * 60 * 1000;
diff --git a/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts
index b3e6fdd..41adccd 100644
--- a/lib/server/validation/createFlowSchemas.ts
+++ b/lib/server/validation/createFlowSchemas.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps";
import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks";
+import { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits";
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
const flowStepTuple = FLOW_STEP_ORDER as unknown as [string, ...string[]];
@@ -64,6 +65,15 @@ const customMethodCardMetaEntrySchema = z.object({
supportText: z.string().max(48),
});
+/** Normalized (trim + lowercase) stakeholder email for drafts + publish. */
+const stakeholderEmailSchema = z
+ .string()
+ .max(320)
+ .transform((s) => s.trim().toLowerCase())
+ .pipe(z.string().email());
+
+export { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits";
+
/**
* Published rule `document` column: arbitrary JSON object with safety bounds.
*/
@@ -144,7 +154,10 @@ export const createFlowStateSchema = z
editingPublishedRuleId: z.string().max(200).optional(),
currentStep: createFlowStepSchema.optional(),
sections: z.array(z.unknown()).optional(),
- stakeholders: z.array(z.unknown()).optional(),
+ stakeholderEmails: z
+ .array(stakeholderEmailSchema)
+ .max(MAX_STAKEHOLDER_EMAILS)
+ .optional(),
})
.passthrough()
.superRefine((data, ctx) => {
@@ -171,10 +184,40 @@ export const publishRuleBodySchema = z.object({
return t.length > 0 ? t : null;
}),
document: publishedRuleDocumentSchema,
+ stakeholderEmails: z
+ .array(stakeholderEmailSchema)
+ .max(MAX_STAKEHOLDER_EMAILS)
+ .optional(),
});
export type PublishRuleBody = z.infer;
+export const postRuleStakeholderBodySchema = z.object({
+ email: stakeholderEmailSchema,
+});
+
+export type PostRuleStakeholderBody = z.infer<
+ typeof postRuleStakeholderBodySchema
+>;
+
+/** Dedupe and drop the publisher’s own email (`emails` need not be pre-normalized). */
+export function uniqueStakeholderEmailsForPublish(
+ emails: string[] | undefined,
+ publisherEmailNormalized: string,
+): string[] {
+ if (!emails?.length) return [];
+ const pub = publisherEmailNormalized.trim().toLowerCase();
+ const seen = new Set();
+ const out: string[] = [];
+ for (const raw of emails) {
+ const e = raw.trim().toLowerCase();
+ if (e === pub || seen.has(e)) continue;
+ seen.add(e);
+ out.push(e);
+ }
+ return out;
+}
+
export const putDraftBodySchema = z.object({
payload: createFlowStateSchema,
});
diff --git a/messages/en/create/reviewAndComplete/confirmStakeholders.json b/messages/en/create/reviewAndComplete/confirmStakeholders.json
index c7bdb2e..27324bb 100644
--- a/messages/en/create/reviewAndComplete/confirmStakeholders.json
+++ b/messages/en/create/reviewAndComplete/confirmStakeholders.json
@@ -1,6 +1,29 @@
{
"title": "Do other stakeholders need to be involved in creating your community?",
- "description": "Adding people at this step will invite them to see your proposed CommunityRule and make their own proposals.",
+ "description": "Add their email addresses. When you publish, we'll send each person a one-time link to join Community Rule and view this rule in their profile (they won't be able to manage it unless they created it).",
"addStakeholder": "Add stakeholder",
- "draftToastTitle": "Congratulations! You've drafted your CommunityRule!"
+ "draftToastTitle": "Congratulations! You've drafted your CommunityRule!",
+ "invalidEmail": "Enter a valid email address.",
+ "duplicateEmail": "That email is already on the list.",
+ "maxStakeholders": "You can add up to 30 stakeholder emails.",
+ "managePublished": {
+ "lockupTitle": "Stakeholders",
+ "lockupDescription": "Invite people by email. They get a one-time link to view this rule in their profile.",
+ "emailLabel": "Email address",
+ "emailPlaceholder": "colleague@example.com",
+ "addInvite": "Send invite",
+ "loading": "Loading…",
+ "loadFailed": "Could not load stakeholders. Refresh and try again.",
+ "pending": "Pending",
+ "accepted": "Accepted",
+ "remove": "Remove",
+ "resend": "Resend invite",
+ "empty": "No stakeholders yet.",
+ "invalidEmail": "Enter a valid email address.",
+ "actionFailed": "Something went wrong. Try again.",
+ "rateLimited": "Too many requests. Try again in {seconds} seconds.",
+ "removeAria": "Remove {email}",
+ "resendAria": "Resend invite to {email}",
+ "footerDone": "Done"
+ }
}
diff --git a/messages/en/create/topNav.json b/messages/en/create/topNav.json
index 103fa92..a0f40f7 100644
--- a/messages/en/create/topNav.json
+++ b/messages/en/create/topNav.json
@@ -6,9 +6,11 @@
"share": "Share",
"export": "Export",
"edit": "Edit",
+ "manageStakeholders": "Manage Stakeholders",
"shareAriaLabel": "Share",
"exportAriaLabel": "Export",
"editAriaLabel": "Edit",
+ "manageStakeholdersAriaLabel": "Manage Stakeholders",
"leaveConfirmLoss": "Leave create flow? Your progress will be lost.",
"draftSaveBannerTitle": "Couldn't save draft",
"postLoginSaveFailedWithReason": "Could not save your draft to your account. Your progress is still stored on this device.\n\n{reason}"
diff --git a/messages/en/pages/login.json b/messages/en/pages/login.json
index 694c911..d7819ad 100644
--- a/messages/en/pages/login.json
+++ b/messages/en/pages/login.json
@@ -20,6 +20,7 @@
"generic": "Something went wrong. Try again.",
"invalidLink": "That sign-in link is not valid. Request a new one from the login page.",
"expiredLink": "That sign-in link has expired. Request a new one from the login page.",
- "serverError": "Something went wrong on our end. Try again later."
+ "serverError": "Something went wrong on our end. Try again later.",
+ "stakeholderWrongAccount": "Sign out and open the stakeholder invite link again, or use the same email you were invited with."
}
}
diff --git a/prisma/migrations/20260509035322_add_rule_stakeholder/migration.sql b/prisma/migrations/20260509035322_add_rule_stakeholder/migration.sql
new file mode 100644
index 0000000..b5781f9
--- /dev/null
+++ b/prisma/migrations/20260509035322_add_rule_stakeholder/migration.sql
@@ -0,0 +1,35 @@
+-- CreateTable
+CREATE TABLE "RuleStakeholder" (
+ "id" TEXT NOT NULL,
+ "ruleId" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "invitedByUserId" TEXT,
+ "userId" TEXT,
+ "inviteTokenHash" TEXT,
+ "inviteExpiresAt" TIMESTAMP(3),
+ "invitedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "acceptedAt" TIMESTAMP(3),
+
+ CONSTRAINT "RuleStakeholder_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "RuleStakeholder_inviteTokenHash_key" ON "RuleStakeholder"("inviteTokenHash");
+
+-- CreateIndex
+CREATE INDEX "RuleStakeholder_userId_idx" ON "RuleStakeholder"("userId");
+
+-- CreateIndex
+CREATE INDEX "RuleStakeholder_email_idx" ON "RuleStakeholder"("email");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "RuleStakeholder_ruleId_email_key" ON "RuleStakeholder"("ruleId", "email");
+
+-- AddForeignKey
+ALTER TABLE "RuleStakeholder" ADD CONSTRAINT "RuleStakeholder_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "PublishedRule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "RuleStakeholder" ADD CONSTRAINT "RuleStakeholder_invitedByUserId_fkey" FOREIGN KEY ("invitedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "RuleStakeholder" ADD CONSTRAINT "RuleStakeholder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 1ef543a..a24a997 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -18,6 +18,10 @@ model User {
rules PublishedRule[]
/// At most one pending verified email change (CR-103).
emailChangeToken EmailChangeToken?
+ /// Rules this user was invited to as a stakeholder (after accepting invite).
+ ruleStakeholders RuleStakeholder[] @relation("RuleStakeholderUser")
+ /// Stakeholder rows where this user sent the invite.
+ stakeholderInvitesSent RuleStakeholder[] @relation("RuleStakeholderInvitedBy")
}
/// Pending email change: user must open verify link sent to `newEmail` (CR-103).
@@ -74,9 +78,35 @@ model PublishedRule {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+ stakeholders RuleStakeholder[]
+
@@index([userId])
}
+/// Invite + access for a published rule: email invite at publish; userId set after magic-link-style accept.
+model RuleStakeholder {
+ id String @id @default(cuid())
+ ruleId String
+ rule PublishedRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
+ /// Normalized lowercase email (invite target).
+ email String
+ /// Publisher at invite time; null if that account was removed.
+ invitedByUserId String?
+ invitedBy User? @relation("RuleStakeholderInvitedBy", fields: [invitedByUserId], references: [id], onDelete: SetNull)
+ /// Set when the invitee completes the verify link (same account as `email`).
+ userId String?
+ user User? @relation("RuleStakeholderUser", fields: [userId], references: [id], onDelete: SetNull)
+ /// One-time invite token (hashed); null after accept or revoke path (consume on verify).
+ inviteTokenHash String? @unique
+ inviteExpiresAt DateTime?
+ invitedAt DateTime @default(now())
+ acceptedAt DateTime?
+
+ @@unique([ruleId, email])
+ @@index([userId])
+ @@index([email])
+}
+
model RuleTemplate {
id String @id @default(cuid())
slug String @unique
diff --git a/stories/navigation/CreateFlowTopNav.stories.js b/stories/navigation/CreateFlowTopNav.stories.js
index ed4d1c4..be092bc 100644
--- a/stories/navigation/CreateFlowTopNav.stories.js
+++ b/stories/navigation/CreateFlowTopNav.stories.js
@@ -8,7 +8,7 @@ export default {
docs: {
description: {
component:
- "Top navigation bar for the create rule flow. Includes logo and action buttons (Share, Export, Edit, Exit/Save & Exit).",
+ "Top navigation bar for the create rule flow. Includes logo and action buttons (Share, Export, Edit, Manage Stakeholders, Exit/Save & Exit).",
},
},
},
@@ -25,6 +25,11 @@ export default {
control: "boolean",
description: "Whether to show the Edit button",
},
+ hasManageStakeholders: {
+ control: "boolean",
+ description:
+ "Whether to show Manage Stakeholders (edit published rule invites)",
+ },
saveDraftOnExit: {
control: "boolean",
description:
@@ -33,6 +38,7 @@ export default {
onShare: { action: "share clicked" },
onSelectExportFormat: { action: "export format" },
onEdit: { action: "edit clicked" },
+ onManageStakeholders: { action: "manage stakeholders clicked" },
onExit: { action: "exit clicked" },
},
tags: ["autodocs"],
@@ -64,3 +70,13 @@ export const SaveDraftOnExit = {
saveDraftOnExit: true,
},
};
+
+export const EditRuleHeader = {
+ args: {
+ hasShare: false,
+ hasExport: false,
+ hasEdit: false,
+ hasManageStakeholders: true,
+ saveDraftOnExit: true,
+ },
+};
diff --git a/tests/components/ConfirmStakeholdersPage.test.tsx b/tests/components/ConfirmStakeholdersPage.test.tsx
index 8f85373..38c2d98 100644
--- a/tests/components/ConfirmStakeholdersPage.test.tsx
+++ b/tests/components/ConfirmStakeholdersPage.test.tsx
@@ -14,7 +14,7 @@ describe("ConfirmStakeholdersScreen", () => {
).toBeInTheDocument();
expect(
screen.getByText(
- /Adding people at this step will invite them to see your proposed CommunityRule/i,
+ /Add their email addresses\. When you publish, we'll send each person a one-time link/i,
),
).toBeInTheDocument();
});
diff --git a/tests/components/CreateFlowTopNav.test.tsx b/tests/components/CreateFlowTopNav.test.tsx
index 041c432..58e6ca1 100644
--- a/tests/components/CreateFlowTopNav.test.tsx
+++ b/tests/components/CreateFlowTopNav.test.tsx
@@ -38,6 +38,8 @@ const config: ComponentTestSuiteConfig = {
onShare: vi.fn(),
onSelectExportFormat: vi.fn(),
onEdit: vi.fn(),
+ hasManageStakeholders: true,
+ onManageStakeholders: vi.fn(),
onExit: vi.fn(),
className: "test-class",
},
@@ -121,6 +123,33 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
expect(editButton).toBeInTheDocument();
});
+ it("renders Manage Stakeholders when hasManageStakeholders is true", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole("button", { name: "Manage Stakeholders" }),
+ ).toBeInTheDocument();
+ });
+
+ it("calls onManageStakeholders when Manage Stakeholders is clicked", async () => {
+ const user = userEvent.setup();
+ const handler = vi.fn();
+ render(
+ ,
+ );
+ await user.click(
+ screen.getByRole("button", { name: "Manage Stakeholders" }),
+ );
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
it("calls onExit when Exit button is clicked", async () => {
const user = userEvent.setup();
const handleExit = vi.fn();
diff --git a/tests/unit/createFlowPaths.test.ts b/tests/unit/createFlowPaths.test.ts
index 60bece8..ebc5139 100644
--- a/tests/unit/createFlowPaths.test.ts
+++ b/tests/unit/createFlowPaths.test.ts
@@ -7,7 +7,10 @@ import {
createFlowStepPathAfterStrippingReviewReturn,
createFlowStepPathWithSyncDraft,
} from "../../app/(app)/create/utils/createFlowPaths";
-import { CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "../../app/(app)/create/utils/flowSteps";
+import {
+ CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
+ CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
+} from "../../app/(app)/create/utils/flowSteps";
describe("createFlowPaths (CR-92 §2)", () => {
it("createFlowStepPath builds segment path", () => {
@@ -26,9 +29,9 @@ describe("createFlowPaths (CR-92 §2)", () => {
);
});
- it("createFlowStepPathAfterStrippingReviewReturn drops reviewReturn only", () => {
+ it("createFlowStepPathAfterStrippingReviewReturn drops reviewReturn and manageStakeholders", () => {
const sp = new URLSearchParams(
- `a=1&${CREATE_FLOW_REVIEW_RETURN_QUERY_KEY}=final-review&b=2`,
+ `a=1&${CREATE_FLOW_REVIEW_RETURN_QUERY_KEY}=final-review&${CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY}=1&b=2`,
);
expect(createFlowStepPathAfterStrippingReviewReturn("final-review", sp)).toBe(
"/create/final-review?a=1&b=2",
diff --git a/tests/unit/createFlowValidation.test.ts b/tests/unit/createFlowValidation.test.ts
index 6f1e99e..31fbe22 100644
--- a/tests/unit/createFlowValidation.test.ts
+++ b/tests/unit/createFlowValidation.test.ts
@@ -7,6 +7,7 @@ import {
createFlowStateSchema,
publishRuleBodySchema,
putDraftBodySchema,
+ uniqueStakeholderEmailsForPublish,
} from "../../lib/server/validation/createFlowSchemas";
describe("assertPlainJsonValue", () => {
@@ -175,6 +176,16 @@ describe("createFlowStateSchema", () => {
});
expect(r.success).toBe(false);
});
+
+ it("accepts stakeholderEmails on draft payload", () => {
+ const r = createFlowStateSchema.safeParse({
+ stakeholderEmails: [" one@example.com "],
+ });
+ expect(r.success).toBe(true);
+ if (r.success) {
+ expect(r.data.stakeholderEmails).toEqual(["one@example.com"]);
+ }
+ });
});
describe("putDraftBodySchema", () => {
@@ -224,4 +235,27 @@ describe("publishRuleBodySchema", () => {
});
expect(r.success).toBe(false);
});
+
+ it("normalizes stakeholderEmails", () => {
+ const r = publishRuleBodySchema.safeParse({
+ title: "Ok",
+ document: {},
+ stakeholderEmails: [" A@Example.COM ", "b@example.com"],
+ });
+ expect(r.success).toBe(true);
+ if (r.success) {
+ expect(r.data.stakeholderEmails).toEqual(["a@example.com", "b@example.com"]);
+ }
+ });
+});
+
+describe("uniqueStakeholderEmailsForPublish", () => {
+ it("dedupes and drops publisher email", () => {
+ expect(
+ uniqueStakeholderEmailsForPublish(
+ ["a@b.c", "A@B.C", "x@y.z"],
+ "a@b.c",
+ ),
+ ).toEqual(["x@y.z"]);
+ });
});
diff --git a/tests/unit/hooks/useCreateFlowFinalize.test.tsx b/tests/unit/hooks/useCreateFlowFinalize.test.tsx
index 9a317a7..d2cdfff 100644
--- a/tests/unit/hooks/useCreateFlowFinalize.test.tsx
+++ b/tests/unit/hooks/useCreateFlowFinalize.test.tsx
@@ -80,6 +80,37 @@ describe("useCreateFlowFinalize", () => {
});
});
+ it("passes stakeholderEmails to publishRule on initial publish", async () => {
+ vi.mocked(publishRule).mockResolvedValue({
+ ok: true,
+ id: "new-rule-id",
+ title: "Published title",
+ });
+
+ const { result } = renderHook(() =>
+ useCreateFlowFinalize({
+ state: {
+ ...emptyState,
+ stakeholderEmails: ["invitee@example.com"],
+ },
+ router,
+ openLogin,
+ updateState,
+ loginReturnPath: "/create/final-review",
+ }),
+ );
+
+ await act(async () => {
+ await result.current.finalize();
+ });
+
+ expect(publishRule).toHaveBeenCalledWith(
+ expect.objectContaining({
+ stakeholderEmails: ["invitee@example.com"],
+ }),
+ );
+ });
+
it("routes to /create/completed without celebrate after PATCH update", async () => {
vi.mocked(updatePublishedRule).mockResolvedValue({ ok: true });
diff --git a/tests/unit/ruleStakeholderInviteVerifyRoute.test.ts b/tests/unit/ruleStakeholderInviteVerifyRoute.test.ts
new file mode 100644
index 0000000..7f1b1cc
--- /dev/null
+++ b/tests/unit/ruleStakeholderInviteVerifyRoute.test.ts
@@ -0,0 +1,100 @@
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const findUniqueMock = vi.fn();
+const updateMock = vi.fn();
+const upsertMock = vi.fn();
+const getSessionUserMock = vi.fn();
+const createSessionMock = vi.fn();
+const setCookieMock = vi.fn();
+
+vi.mock("../../lib/server/db", () => ({
+ prisma: {
+ ruleStakeholder: {
+ findUnique: (...args: unknown[]) => findUniqueMock(...args),
+ update: (...args: unknown[]) => updateMock(...args),
+ },
+ user: {
+ upsert: (...args: unknown[]) => upsertMock(...args),
+ },
+ },
+}));
+
+vi.mock("../../lib/server/env", () => ({
+ isDatabaseConfigured: () => true,
+ getSessionPepper: () => "pepper",
+}));
+
+vi.mock("../../lib/server/hash", () => ({
+ hashSessionToken: (t: string) => `h-${t}`,
+}));
+
+vi.mock("../../lib/server/session", () => ({
+ getSessionUser: () => getSessionUserMock(),
+ createSessionForUser: (...args: unknown[]) => createSessionMock(...args),
+ setSessionCookie: (...args: unknown[]) => setCookieMock(...args),
+}));
+
+import { GET } from "../../app/api/invites/rule-stakeholder/verify/route";
+
+beforeEach(() => {
+ findUniqueMock.mockReset();
+ updateMock.mockReset();
+ upsertMock.mockReset();
+ getSessionUserMock.mockReset();
+ createSessionMock.mockReset();
+ setCookieMock.mockReset();
+});
+
+describe("GET /api/invites/rule-stakeholder/verify", () => {
+ it("redirects to the rule after a valid token", async () => {
+ getSessionUserMock.mockResolvedValue(null);
+ findUniqueMock.mockResolvedValue({
+ id: "st1",
+ email: "inv@example.com",
+ ruleId: "rule-1",
+ inviteExpiresAt: new Date(Date.now() + 60_000),
+ });
+ upsertMock.mockResolvedValue({ id: "u1", email: "inv@example.com" });
+ updateMock.mockResolvedValue({});
+ createSessionMock.mockResolvedValue({
+ token: "sess",
+ expiresAt: new Date(),
+ });
+
+ const res = await GET(
+ new NextRequest(
+ `https://x.test/api/invites/rule-stakeholder/verify?token=${"x".repeat(12)}`,
+ ),
+ );
+
+ expect(res.status).toBeGreaterThanOrEqual(300);
+ expect(res.status).toBeLessThan(400);
+ expect(res.headers.get("location")).toContain("/rules/rule-1");
+ expect(setCookieMock).toHaveBeenCalled();
+ });
+
+ it("redirects to login when another user is already signed in", async () => {
+ getSessionUserMock.mockResolvedValue({
+ id: "u-other",
+ email: "other@example.com",
+ });
+ findUniqueMock.mockResolvedValue({
+ id: "st1",
+ email: "inv@example.com",
+ ruleId: "rule-1",
+ inviteExpiresAt: new Date(Date.now() + 60_000),
+ });
+
+ const res = await GET(
+ new NextRequest(
+ `https://x.test/api/invites/rule-stakeholder/verify?token=${"y".repeat(12)}`,
+ ),
+ );
+
+ expect(res.headers.get("location")).toContain(
+ "error=stakeholder_wrong_account",
+ );
+ expect(upsertMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/rulesMeRoute.test.ts b/tests/unit/rulesMeRoute.test.ts
index 41392a1..13f56b5 100644
--- a/tests/unit/rulesMeRoute.test.ts
+++ b/tests/unit/rulesMeRoute.test.ts
@@ -2,7 +2,7 @@ import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const isDatabaseConfiguredMock = vi.fn();
-const listForUserMock = vi.fn();
+const listProfileMock = vi.fn();
const getSessionUserMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
@@ -10,7 +10,7 @@ vi.mock("../../lib/server/env", () => ({
}));
vi.mock("../../lib/server/publishedRules", () => ({
- listPublishedRulesForUser: (...args: unknown[]) => listForUserMock(...args),
+ listProfileRulesForUser: (...args: unknown[]) => listProfileMock(...args),
}));
vi.mock("../../lib/server/session", () => ({
@@ -21,7 +21,7 @@ import { GET } from "../../app/api/rules/me/route";
beforeEach(() => {
isDatabaseConfiguredMock.mockReset();
- listForUserMock.mockReset();
+ listProfileMock.mockReset();
getSessionUserMock.mockReset();
});
@@ -44,7 +44,7 @@ describe("GET /api/rules/me", () => {
undefined,
);
expect(res.status).toBe(401);
- expect(listForUserMock).not.toHaveBeenCalled();
+ expect(listProfileMock).not.toHaveBeenCalled();
});
it("returns 200 with { rules } for the session user", async () => {
@@ -59,17 +59,20 @@ describe("GET /api/rules/me", () => {
updatedAt: new Date("2026-01-02T00:00:00Z"),
},
];
- listForUserMock.mockResolvedValueOnce(rows);
+ listProfileMock.mockResolvedValueOnce(
+ rows.map((r) => ({ ...r, role: "owner" as const })),
+ );
const res = await GET(
new NextRequest("https://x.test/api/rules/me?limit=10"),
undefined,
);
expect(res.status).toBe(200);
- expect(listForUserMock).toHaveBeenCalledWith("user-1", 10);
+ expect(listProfileMock).toHaveBeenCalledWith("user-1", 10);
const body = (await res.json()) as {
- rules: Array<{ id: string; title: string }>;
+ rules: Array<{ id: string; title: string; role: string }>;
};
expect(body.rules).toHaveLength(1);
expect(body.rules[0].id).toBe("r1");
+ expect(body.rules[0].role).toBe("owner");
});
});
diff --git a/tests/unit/rulesPublishPostRoute.test.ts b/tests/unit/rulesPublishPostRoute.test.ts
new file mode 100644
index 0000000..eadd849
--- /dev/null
+++ b/tests/unit/rulesPublishPostRoute.test.ts
@@ -0,0 +1,174 @@
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const getSessionUserMock = vi.fn();
+const transactionMock = vi.fn();
+const publishedRuleCreateMock = vi.fn();
+const publishedRuleDeleteMock = vi.fn();
+const sendInviteMock = vi.fn();
+
+vi.mock("../../lib/server/env", () => ({
+ isDatabaseConfigured: () => true,
+ getSessionPepper: () => "test-pepper",
+}));
+
+vi.mock("../../lib/server/session", () => ({
+ getSessionUser: () => getSessionUserMock(),
+}));
+
+vi.mock("../../lib/server/rateLimit", () => ({
+ rateLimitKey: () => ({ ok: true as const }),
+}));
+
+vi.mock("../../lib/server/mail", () => ({
+ sendRuleStakeholderInviteEmail: (...args: unknown[]) =>
+ sendInviteMock(...args),
+}));
+
+vi.mock("../../lib/server/hash", () => ({
+ newSessionToken: () => "x".repeat(32),
+ hashSessionToken: (t: string) => `hashed-${t}`,
+}));
+
+vi.mock("../../lib/server/db", () => ({
+ prisma: {
+ $transaction: (fn: (tx: unknown) => Promise) =>
+ transactionMock(fn),
+ publishedRule: {
+ create: (...args: unknown[]) => publishedRuleCreateMock(...args),
+ delete: (...args: unknown[]) => publishedRuleDeleteMock(...args),
+ },
+ ruleStakeholder: {
+ create: vi.fn().mockResolvedValue({}),
+ },
+ },
+}));
+
+import { POST } from "../../app/api/rules/route";
+
+beforeEach(() => {
+ getSessionUserMock.mockReset();
+ transactionMock.mockReset();
+ publishedRuleCreateMock.mockReset();
+ publishedRuleDeleteMock.mockReset();
+ sendInviteMock.mockReset();
+ getSessionUserMock.mockResolvedValue({
+ id: "user-1",
+ email: "owner@example.com",
+ });
+});
+
+describe("POST /api/rules", () => {
+ it("creates rule without transaction when there are no stakeholder emails", async () => {
+ publishedRuleCreateMock.mockResolvedValueOnce({
+ id: "rule-solo",
+ title: "T",
+ summary: null,
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ });
+
+ const res = await POST(
+ new NextRequest("https://x.test/api/rules", {
+ method: "POST",
+ body: JSON.stringify({
+ title: "T",
+ summary: null,
+ document: {},
+ }),
+ }),
+ undefined,
+ );
+
+ expect(res.status).toBe(200);
+ expect(transactionMock).not.toHaveBeenCalled();
+ expect(publishedRuleCreateMock).toHaveBeenCalledTimes(1);
+ expect(sendInviteMock).not.toHaveBeenCalled();
+ });
+
+ it("uses a transaction and sends stakeholder invites", async () => {
+ const created = {
+ id: "rule-new",
+ title: "Published title",
+ summary: null,
+ createdAt: new Date("2026-01-02T00:00:00.000Z"),
+ };
+ transactionMock.mockImplementation(
+ async (fn: (tx: { publishedRule: { create: typeof vi.fn }; ruleStakeholder: { create: typeof vi.fn } }) => Promise) => {
+ const tx = {
+ publishedRule: {
+ create: vi.fn().mockResolvedValue(created),
+ },
+ ruleStakeholder: {
+ create: vi.fn().mockResolvedValue({}),
+ },
+ };
+ return fn(tx);
+ },
+ );
+ sendInviteMock.mockResolvedValue(undefined);
+
+ const res = await POST(
+ new NextRequest("https://x.test/api/rules", {
+ method: "POST",
+ body: JSON.stringify({
+ title: "Published title",
+ summary: null,
+ document: {},
+ stakeholderEmails: ["stakeholder@example.com"],
+ }),
+ }),
+ undefined,
+ );
+
+ expect(res.status).toBe(200);
+ expect(transactionMock).toHaveBeenCalledTimes(1);
+ expect(sendInviteMock).toHaveBeenCalledTimes(1);
+ expect(sendInviteMock.mock.calls[0][0]).toBe("stakeholder@example.com");
+ expect(String(sendInviteMock.mock.calls[0][1])).toContain(
+ "/api/invites/rule-stakeholder/verify?token=",
+ );
+ expect(publishedRuleCreateMock).not.toHaveBeenCalled();
+ });
+
+ it("rolls back publish when mail fails", async () => {
+ const created = {
+ id: "rule-new",
+ title: "Published title",
+ summary: null,
+ createdAt: new Date("2026-01-02T00:00:00.000Z"),
+ };
+ transactionMock.mockImplementation(
+ async (fn: (tx: { publishedRule: { create: typeof vi.fn }; ruleStakeholder: { create: typeof vi.fn } }) => Promise) => {
+ const tx = {
+ publishedRule: {
+ create: vi.fn().mockResolvedValue(created),
+ },
+ ruleStakeholder: {
+ create: vi.fn().mockResolvedValue({}),
+ },
+ };
+ return fn(tx);
+ },
+ );
+ sendInviteMock.mockRejectedValueOnce(new Error("smtp down"));
+ publishedRuleDeleteMock.mockResolvedValueOnce({});
+
+ const res = await POST(
+ new NextRequest("https://x.test/api/rules", {
+ method: "POST",
+ body: JSON.stringify({
+ title: "Published title",
+ summary: null,
+ document: {},
+ stakeholderEmails: ["stakeholder@example.com"],
+ }),
+ }),
+ undefined,
+ );
+
+ expect(res.status).toBe(502);
+ expect(publishedRuleDeleteMock).toHaveBeenCalledWith({
+ where: { id: "rule-new" },
+ });
+ });
+});
diff --git a/tests/unit/rulesStakeholdersRoutes.test.ts b/tests/unit/rulesStakeholdersRoutes.test.ts
new file mode 100644
index 0000000..29722b7
--- /dev/null
+++ b/tests/unit/rulesStakeholdersRoutes.test.ts
@@ -0,0 +1,79 @@
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const findManyStakeholdersMock = vi.fn();
+const findFirstRuleMock = vi.fn();
+const getSessionUserMock = vi.fn();
+
+vi.mock("../../lib/server/env", () => ({
+ isDatabaseConfigured: () => true,
+}));
+
+vi.mock("../../lib/server/db", () => ({
+ prisma: {
+ publishedRule: {
+ findFirst: (...args: unknown[]) => findFirstRuleMock(...args),
+ },
+ ruleStakeholder: {
+ findMany: (...args: unknown[]) => findManyStakeholdersMock(...args),
+ },
+ },
+}));
+
+vi.mock("../../lib/server/session", () => ({
+ getSessionUser: () => getSessionUserMock(),
+}));
+
+import { GET } from "../../app/api/rules/[id]/stakeholders/route";
+
+beforeEach(() => {
+ findManyStakeholdersMock.mockReset();
+ findFirstRuleMock.mockReset();
+ getSessionUserMock.mockReset();
+ getSessionUserMock.mockResolvedValue({ id: "owner-1", email: "o@example.com" });
+});
+
+describe("GET /api/rules/[id]/stakeholders", () => {
+ it("returns 401 when unauthenticated", async () => {
+ getSessionUserMock.mockResolvedValueOnce(null);
+ const res = await GET(
+ new NextRequest("https://x.test/api/rules/r1/stakeholders"),
+ { params: Promise.resolve({ id: "r1" }) },
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it("returns stakeholders for the rule owner", async () => {
+ findFirstRuleMock.mockResolvedValueOnce({
+ id: "r1",
+ title: "My rule",
+ });
+ findManyStakeholdersMock.mockResolvedValueOnce([
+ {
+ id: "s1",
+ email: "a@b.c",
+ invitedAt: new Date("2026-01-01T00:00:00Z"),
+ acceptedAt: null,
+ inviteTokenHash: "hash",
+ },
+ {
+ id: "s2",
+ email: "x@y.z",
+ invitedAt: new Date("2026-01-02T00:00:00Z"),
+ acceptedAt: new Date("2026-01-03T00:00:00Z"),
+ inviteTokenHash: null,
+ },
+ ]);
+ const res = await GET(
+ new NextRequest("https://x.test/api/rules/r1/stakeholders"),
+ { params: Promise.resolve({ id: "r1" }) },
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ stakeholders: Array<{ status: string; email: string }>;
+ };
+ expect(body.stakeholders).toHaveLength(2);
+ expect(body.stakeholders[0].status).toBe("pending");
+ expect(body.stakeholders[1].status).toBe("accepted");
+ });
+});
--
2.43.0
From b5930331c0ac937c302df289dc60fb04b426162c Mon Sep 17 00:00:00 2001
From: adilallo <39313955+adilallo@users.noreply.github.com>
Date: Sat, 9 May 2026 23:09:51 -0600
Subject: [PATCH 5/6] Remove progress bar from edit rule screens
---
app/(app)/create/CreateFlowLayoutClient.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx
index 1fe7479..729d37f 100644
--- a/app/(app)/create/CreateFlowLayoutClient.tsx
+++ b/app/(app)/create/CreateFlowLayoutClient.tsx
@@ -644,7 +644,11 @@ function CreateFlowLayoutContent({
{!isCompletedStep && (
Date: Mon, 11 May 2026 18:03:52 -0600
Subject: [PATCH 6/6] Ask organizer modal implemented
---
.env.example | 3 +
app/(marketing)/blog/[slug]/page.tsx | 1 -
app/(marketing)/learn/page.tsx | 1 -
app/(marketing)/page.tsx | 1 -
app/api/organizer-inquiry/route.ts | 89 ++++++++++
.../AskOrganizerInquiryModal.container.tsx | 124 +++++++++++++
.../AskOrganizerInquiryModal.types.ts | 4 +
.../AskOrganizerInquiryModal.view.tsx | 164 ++++++++++++++++++
.../modals/AskOrganizerInquiry/index.tsx | 2 +
.../modals/Create/Create.container.tsx | 2 +
app/components/modals/Create/Create.types.ts | 3 +
app/components/modals/Create/Create.view.tsx | 2 +
.../AskOrganizer/AskOrganizer.container.tsx | 71 ++++++--
.../AskOrganizer/AskOrganizer.types.ts | 7 +-
.../AskOrganizer/AskOrganizer.view.tsx | 3 +-
lib/organizerInquiryConstants.ts | 7 +
lib/server/mail.ts | 33 ++++
.../validation/organizerInquirySchemas.ts | 30 ++++
messages/en/components/askOrganizer.json | 3 +-
messages/en/index.ts | 2 +
messages/en/modals/askOrganizerInquiry.json | 17 ++
messages/en/pages/home.json | 3 +-
messages/en/pages/learn.json | 3 +-
stories/sections/AskOrganizer.stories.js | 16 +-
tests/components/AskOrganizer.test.tsx | 14 +-
tests/e2e/critical-journeys.spec.ts | 16 +-
tests/pages/page-flow.test.jsx | 20 ++-
tests/pages/user-journey.test.jsx | 9 +-
tests/unit/organizerInquiryPostRoute.test.ts | 130 ++++++++++++++
29 files changed, 724 insertions(+), 56 deletions(-)
create mode 100644 app/api/organizer-inquiry/route.ts
create mode 100644 app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.container.tsx
create mode 100644 app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.types.ts
create mode 100644 app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx
create mode 100644 app/components/modals/AskOrganizerInquiry/index.tsx
create mode 100644 lib/organizerInquiryConstants.ts
create mode 100644 lib/server/validation/organizerInquirySchemas.ts
create mode 100644 messages/en/modals/askOrganizerInquiry.json
create mode 100644 tests/unit/organizerInquiryPostRoute.test.ts
diff --git a/.env.example b/.env.example
index e3cdfdf..513cd81 100644
--- a/.env.example
+++ b/.env.example
@@ -11,6 +11,9 @@ SESSION_SECRET="dev-only-change-me-16chars-min"
SMTP_URL=
SMTP_FROM="Community Rule "
+# CR-107: inbox for Ask an organizer form submissions (requires SMTP_URL in production).
+ORGANIZER_INQUIRY_TO=
+
# Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in.
NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
diff --git a/app/(marketing)/blog/[slug]/page.tsx b/app/(marketing)/blog/[slug]/page.tsx
index a2a7c21..b1339c3 100644
--- a/app/(marketing)/blog/[slug]/page.tsx
+++ b/app/(marketing)/blog/[slug]/page.tsx
@@ -28,7 +28,6 @@ const askOrganizerData = {
title: "Still have questions?",
subtitle: "Get answers from an experienced organizer",
buttonText: "Ask an organizer",
- buttonHref: "#contact",
};
interface PageProps {
diff --git a/app/(marketing)/learn/page.tsx b/app/(marketing)/learn/page.tsx
index 1644679..49909ee 100644
--- a/app/(marketing)/learn/page.tsx
+++ b/app/(marketing)/learn/page.tsx
@@ -24,7 +24,6 @@ export default function LearnPage() {
subtitle: t("pages.learn.askOrganizer.subtitle"),
description: t("pages.learn.askOrganizer.description"),
buttonText: t("pages.learn.askOrganizer.buttonText"),
- buttonHref: t("pages.learn.askOrganizer.buttonHref"),
variant: "centered" as const,
};
diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx
index d6cbb64..c75d8af 100644
--- a/app/(marketing)/page.tsx
+++ b/app/(marketing)/page.tsx
@@ -87,7 +87,6 @@ export default function Page() {
title: t("pages.home.askOrganizer.title"),
subtitle: t("pages.home.askOrganizer.subtitle"),
buttonText: t("pages.home.askOrganizer.buttonText"),
- buttonHref: t("pages.home.askOrganizer.buttonHref"),
};
return (
diff --git a/app/api/organizer-inquiry/route.ts b/app/api/organizer-inquiry/route.ts
new file mode 100644
index 0000000..44632bc
--- /dev/null
+++ b/app/api/organizer-inquiry/route.ts
@@ -0,0 +1,89 @@
+import { NextRequest, NextResponse } from "next/server";
+import { sendOrganizerInquiryNotification } from "../../../lib/server/mail";
+import { rateLimitKey } from "../../../lib/server/rateLimit";
+import {
+ errorJson,
+ rateLimited,
+ serverMisconfigured,
+} from "../../../lib/server/responses";
+import { logRouteError } from "../../../lib/server/requestId";
+import { apiRoute } from "../../../lib/server/apiRoute";
+import { ORGANIZER_INQUIRY_HONEYPOT_FIELD } from "../../../lib/organizerInquiryConstants";
+import { organizerInquiryBodySchema } from "../../../lib/server/validation/organizerInquirySchemas";
+import { readLimitedJson } from "../../../lib/server/validation/requestBody";
+import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
+
+const SCOPE = "organizer-inquiry.submit";
+const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
+const IP_MIN_INTERVAL_MS = 20 * 1000;
+
+function clientIp(request: NextRequest): string {
+ return (
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
+ request.headers.get("x-real-ip") ??
+ "unknown"
+ );
+}
+
+function organizerInquiryTo(): string | null {
+ const raw = process.env.ORGANIZER_INQUIRY_TO?.trim();
+ return raw && raw.length > 0 ? raw : null;
+}
+
+export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => {
+ const parsedBody = await readLimitedJson(request);
+ if (parsedBody.ok === false) {
+ return parsedBody.response;
+ }
+
+ const validated = organizerInquiryBodySchema.safeParse(parsedBody.value);
+ if (!validated.success) {
+ return jsonFromZodError(validated.error);
+ }
+
+ const { email, message } = validated.data;
+ const honeypot = validated.data[ORGANIZER_INQUIRY_HONEYPOT_FIELD];
+
+ if (honeypot.length > 0) {
+ // Silent success for bots — do not send mail or reveal rejection.
+ return NextResponse.json({ ok: true });
+ }
+
+ const ip = clientIp(request);
+
+ const rlEmail = rateLimitKey(`organizer-inquiry-email:${email}`, EMAIL_MIN_INTERVAL_MS);
+ if (rlEmail.ok === false) {
+ return rateLimited(rlEmail.retryAfterMs);
+ }
+
+ const rlIp = rateLimitKey(`organizer-inquiry-ip:${ip}`, IP_MIN_INTERVAL_MS);
+ if (rlIp.ok === false) {
+ return rateLimited(rlIp.retryAfterMs);
+ }
+
+ const to = organizerInquiryTo();
+ if (!to) {
+ return serverMisconfigured("ORGANIZER_INQUIRY_TO is not configured");
+ }
+
+ const from = process.env.SMTP_FROM ?? "noreply@localhost";
+
+ try {
+ await sendOrganizerInquiryNotification({
+ to,
+ fromEmail: from,
+ visitorEmail: email,
+ message,
+ requestId,
+ });
+ } catch (err) {
+ logRouteError(SCOPE, requestId, err, { phase: "sendOrganizerInquiryNotification" });
+ return errorJson(
+ "mail_failed",
+ "We could not send your message. Please try again later.",
+ 502,
+ );
+ }
+
+ return NextResponse.json({ ok: true });
+});
diff --git a/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.container.tsx b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.container.tsx
new file mode 100644
index 0000000..b381a20
--- /dev/null
+++ b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.container.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+/**
+ * Figma: Community Rule System — Modal / Ask an Organizer (22078-587823)
+ * File: agv0VBLiBlcnSAaiAORgPR, node 22078-587823
+ */
+
+import { memo, useCallback, useEffect, useState, type FormEvent } from "react";
+import { AskOrganizerInquiryModalView } from "./AskOrganizerInquiryModal.view";
+import type { AskOrganizerInquiryModalProps } from "./AskOrganizerInquiryModal.types";
+import { ORGANIZER_INQUIRY_HONEYPOT_FIELD } from "../../../../lib/organizerInquiryConstants";
+import { useTranslation } from "../../../contexts/MessagesContext";
+
+const AskOrganizerInquiryModalContainer = memo(
+ ({ isOpen, onClose }) => {
+ const t = useTranslation("modals.askOrganizerInquiry");
+ const [email, setEmail] = useState("");
+ const [message, setMessage] = useState("");
+ const [honeypot, setHoneypot] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [success, setSuccess] = useState(false);
+ const [formError, setFormError] = useState(null);
+ const [emailError, setEmailError] = useState(false);
+ const [questionError, setQuestionError] = useState(false);
+
+ useEffect(() => {
+ if (!isOpen) {
+ setEmail("");
+ setMessage("");
+ setHoneypot("");
+ setSubmitting(false);
+ setSuccess(false);
+ setFormError(null);
+ setEmailError(false);
+ setQuestionError(false);
+ }
+ }, [isOpen]);
+
+ const onSubmit = useCallback(
+ async (e: FormEvent) => {
+ e.preventDefault();
+ setFormError(null);
+ setEmailError(false);
+ setQuestionError(false);
+ setSubmitting(true);
+
+ try {
+ const res = await fetch("/api/organizer-inquiry", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ email,
+ message,
+ [ORGANIZER_INQUIRY_HONEYPOT_FIELD]: honeypot,
+ }),
+ });
+
+ const data: unknown = await res.json().catch(() => null);
+
+ if (res.ok) {
+ setSuccess(true);
+ return;
+ }
+
+ if (res.status === 429) {
+ setFormError(t("rateLimitedError"));
+ return;
+ }
+
+ if (
+ data &&
+ typeof data === "object" &&
+ "error" in data &&
+ data.error &&
+ typeof data.error === "object" &&
+ "message" in data.error &&
+ typeof (data.error as { message: unknown }).message === "string"
+ ) {
+ const msg = (data.error as { message: string }).message;
+ const lower = msg.toLowerCase();
+ if (lower.includes("email")) {
+ setEmailError(true);
+ }
+ if (lower.includes("character") || lower.includes("question")) {
+ setQuestionError(true);
+ }
+ setFormError(msg);
+ return;
+ }
+
+ setFormError(t("genericError"));
+ } catch {
+ setFormError(t("genericError"));
+ } finally {
+ setSubmitting(false);
+ }
+ },
+ [email, message, honeypot, t],
+ );
+
+ return (
+
+ );
+ },
+);
+
+AskOrganizerInquiryModalContainer.displayName = "AskOrganizerInquiryModal";
+
+export default AskOrganizerInquiryModalContainer;
diff --git a/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.types.ts b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.types.ts
new file mode 100644
index 0000000..f6abe4e
--- /dev/null
+++ b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.types.ts
@@ -0,0 +1,4 @@
+export interface AskOrganizerInquiryModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
diff --git a/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx
new file mode 100644
index 0000000..95c2d27
--- /dev/null
+++ b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import type { FormEvent } from "react";
+import Create from "../Create";
+import TextInput from "../../controls/TextInput";
+import TextArea from "../../controls/TextArea";
+import Button from "../../buttons/Button";
+import { useTranslation } from "../../../contexts/MessagesContext";
+import {
+ ASK_ORGANIZER_INQUIRY_FORM_ID,
+ ORGANIZER_INQUIRY_HONEYPOT_FIELD,
+} from "../../../../lib/organizerInquiryConstants";
+import type { AskOrganizerInquiryModalProps } from "./AskOrganizerInquiryModal.types";
+
+export type AskOrganizerInquiryModalViewProps = AskOrganizerInquiryModalProps & {
+ email: string;
+ message: string;
+ honeypot: string;
+ submitting: boolean;
+ success: boolean;
+ formError: string | null;
+ emailError: boolean;
+ questionError: boolean;
+ onEmailChange: (_v: string) => void;
+ onMessageChange: (_v: string) => void;
+ onHoneypotChange: (_v: string) => void;
+ onSubmit: (_e: FormEvent) => void;
+};
+
+/**
+ * Figma: Community Rule System — Modal / Ask an Organizer (22078-587823)
+ */
+export function AskOrganizerInquiryModalView({
+ isOpen,
+ onClose,
+ email,
+ message,
+ honeypot,
+ submitting,
+ success,
+ formError,
+ emailError,
+ questionError,
+ onEmailChange,
+ onMessageChange,
+ onHoneypotChange,
+ onSubmit,
+}: AskOrganizerInquiryModalViewProps) {
+ const t = useTranslation("modals.askOrganizerInquiry");
+
+ const footer = success ? (
+
+
+ {t("closeAfterSuccess")}
+
+
+ ) : (
+
+
+ {t("submitButton")}
+
+
+ );
+
+ return (
+
+ {success ? (
+
+
+ {t("successTitle")}
+
+
+ {t("successDescription")}
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/app/components/modals/AskOrganizerInquiry/index.tsx b/app/components/modals/AskOrganizerInquiry/index.tsx
new file mode 100644
index 0000000..f1f3360
--- /dev/null
+++ b/app/components/modals/AskOrganizerInquiry/index.tsx
@@ -0,0 +1,2 @@
+export { default } from "./AskOrganizerInquiryModal.container";
+export * from "./AskOrganizerInquiryModal.types";
diff --git a/app/components/modals/Create/Create.container.tsx b/app/components/modals/Create/Create.container.tsx
index 076b5c6..1645427 100644
--- a/app/components/modals/Create/Create.container.tsx
+++ b/app/components/modals/Create/Create.container.tsx
@@ -14,6 +14,7 @@ const CreateContainer = memo(
headerContent,
children,
footerContent,
+ footerClassName,
showBackButton = true,
showNextButton = true,
onBack,
@@ -47,6 +48,7 @@ const CreateContainer = memo(
// eslint-disable-next-line react/no-children-prop
children={children}
footerContent={footerContent}
+ footerClassName={footerClassName}
showBackButton={showBackButton}
showNextButton={showNextButton}
onBack={onBack}
diff --git a/app/components/modals/Create/Create.types.ts b/app/components/modals/Create/Create.types.ts
index 7a3756c..25500c4 100644
--- a/app/components/modals/Create/Create.types.ts
+++ b/app/components/modals/Create/Create.types.ts
@@ -12,6 +12,8 @@ export interface CreateProps {
headerContent?: React.ReactNode;
children?: React.ReactNode;
footerContent?: React.ReactNode;
+ /** Optional class on {@link ModalFooter} shell (e.g. taller custom footer). */
+ footerClassName?: string;
showBackButton?: boolean;
showNextButton?: boolean;
onBack?: () => void;
@@ -51,6 +53,7 @@ export interface CreateViewProps {
headerContent?: React.ReactNode;
children?: React.ReactNode;
footerContent?: React.ReactNode;
+ footerClassName?: string;
showBackButton: boolean;
showNextButton: boolean;
onBack?: () => void;
diff --git a/app/components/modals/Create/Create.view.tsx b/app/components/modals/Create/Create.view.tsx
index 9a25e71..30bf18d 100644
--- a/app/components/modals/Create/Create.view.tsx
+++ b/app/components/modals/Create/Create.view.tsx
@@ -14,6 +14,7 @@ export function CreateView({
headerContent,
children,
footerContent,
+ footerClassName,
showBackButton,
showNextButton,
onBack,
@@ -82,6 +83,7 @@ export function CreateView({
totalSteps={totalSteps}
stepper={stepper}
footerContent={footerContent}
+ className={footerClassName}
/>
);
diff --git a/app/components/sections/AskOrganizer/AskOrganizer.container.tsx b/app/components/sections/AskOrganizer/AskOrganizer.container.tsx
index a1bc8d6..136795c 100644
--- a/app/components/sections/AskOrganizer/AskOrganizer.container.tsx
+++ b/app/components/sections/AskOrganizer/AskOrganizer.container.tsx
@@ -1,8 +1,9 @@
"use client";
-import { memo } from "react";
+import { memo, useCallback, useState } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useAnalytics } from "../../../hooks";
+import AskOrganizerInquiryModal from "../../modals/AskOrganizerInquiry";
import AskOrganizerView from "./AskOrganizer.view";
import type {
AskOrganizerProps,
@@ -45,8 +46,9 @@ const AskOrganizerContainer = memo(
const variant = variantProp;
const t = useTranslation();
const defaultButtonText = buttonText ?? t("askOrganizer.buttonText");
- const defaultButtonHref = buttonHref ?? t("askOrganizer.buttonHref");
+ const analyticsHref = buttonHref ?? "modal";
const { trackEvent, trackCustomEvent } = useAnalytics();
+ const [inquiryOpen, setInquiryOpen] = useState(false);
const resolvedVariant: AskOrganizerVariant = variant ?? "centered";
const styles = VARIANT_STYLES[resolvedVariant] ?? VARIANT_STYLES.centered;
@@ -66,6 +68,31 @@ const AskOrganizerContainer = memo(
const handleContactClick = (
event: React.MouseEvent,
) => {
+ if (buttonHref) {
+ // Legacy link CTA: do not intercept navigation.
+ trackEvent({
+ event: "contact_button_click",
+ category: "engagement",
+ label: "ask_organizer",
+ component: "AskOrganizer",
+ variant: resolvedVariant,
+ });
+ trackCustomEvent(
+ "contact_button_click",
+ {
+ component: "AskOrganizer",
+ variant: resolvedVariant,
+ buttonText: defaultButtonText,
+ buttonHref: analyticsHref,
+ },
+ onContactClick as
+ | ((_data: Record) => void)
+ | undefined,
+ );
+ return event;
+ }
+
+ event.preventDefault();
trackEvent({
event: "contact_button_click",
category: "engagement",
@@ -80,33 +107,39 @@ const AskOrganizerContainer = memo(
component: "AskOrganizer",
variant: resolvedVariant,
buttonText: defaultButtonText,
- buttonHref: defaultButtonHref,
+ buttonHref: analyticsHref,
},
onContactClick as
| ((_data: Record) => void)
| undefined,
);
- // Preserve existing button behavior (no preventDefault here)
- // while still tracking analytics.
+ setInquiryOpen(true);
return event;
};
+ const closeInquiry = useCallback(() => {
+ setInquiryOpen(false);
+ }, []);
+
return (
-
+ <>
+
+
+ >
);
},
);
diff --git a/app/components/sections/AskOrganizer/AskOrganizer.types.ts b/app/components/sections/AskOrganizer/AskOrganizer.types.ts
index 29c2bb7..3d70ac4 100644
--- a/app/components/sections/AskOrganizer/AskOrganizer.types.ts
+++ b/app/components/sections/AskOrganizer/AskOrganizer.types.ts
@@ -11,6 +11,9 @@ export interface AskOrganizerProps {
subtitle?: string;
description?: string;
buttonText?: string;
+ /**
+ * @deprecated Modal-only flow (CR-107). Omit; kept optional for Storybook overrides.
+ */
buttonHref?: string;
className?: string;
/**
@@ -22,7 +25,7 @@ export interface AskOrganizerProps {
component: string;
variant: string;
buttonText: string;
- buttonHref: string;
+ buttonHref?: string;
timestamp: string;
}) => void;
}
@@ -32,7 +35,7 @@ export interface AskOrganizerViewProps {
subtitle?: string;
description?: string;
buttonText: string;
- buttonHref: string;
+ buttonHref?: string;
className: string;
sectionPadding: string;
contentGap: string;
diff --git a/app/components/sections/AskOrganizer/AskOrganizer.view.tsx b/app/components/sections/AskOrganizer/AskOrganizer.view.tsx
index e6c16be..4a99236 100644
--- a/app/components/sections/AskOrganizer/AskOrganizer.view.tsx
+++ b/app/components/sections/AskOrganizer/AskOrganizer.view.tsx
@@ -43,13 +43,14 @@ function AskOrganizerView({
{/* Button */}
{buttonText}
diff --git a/lib/organizerInquiryConstants.ts b/lib/organizerInquiryConstants.ts
new file mode 100644
index 0000000..4467dfc
--- /dev/null
+++ b/lib/organizerInquiryConstants.ts
@@ -0,0 +1,7 @@
+/**
+ * Shared between client (form JSON) and server (Zod + honeypot check).
+ * CR-107 Ask an organizer.
+ */
+export const ORGANIZER_INQUIRY_HONEYPOT_FIELD = "company" as const;
+
+export const ASK_ORGANIZER_INQUIRY_FORM_ID = "ask-organizer-inquiry-form" as const;
diff --git a/lib/server/mail.ts b/lib/server/mail.ts
index 83ef508..44f4da2 100644
--- a/lib/server/mail.ts
+++ b/lib/server/mail.ts
@@ -56,6 +56,39 @@ export async function sendRuleStakeholderInviteEmail(
});
}
+/** CR-107: notify support/organizers when a visitor submits the Ask an organizer form. */
+export async function sendOrganizerInquiryNotification(params: {
+ /** Destination inbox (e.g. from ORGANIZER_INQUIRY_TO). */
+ to: string;
+ fromEmail: string;
+ visitorEmail: string;
+ message: string;
+ requestId: string;
+}): Promise
{
+ const { to, fromEmail, visitorEmail, message, requestId } = params;
+ const url = process.env.SMTP_URL;
+
+ if (!url) {
+ if (process.env.NODE_ENV === "development") {
+ logger.info(
+ `[dev] Organizer inquiry (request ${requestId}) from ${visitorEmail} to ${to}:\n${message}`,
+ );
+ return;
+ }
+ throw new Error("SMTP_URL is not configured");
+ }
+
+ const transporter = nodemailer.createTransport(url);
+
+ await transporter.sendMail({
+ from: fromEmail,
+ to,
+ replyTo: visitorEmail,
+ subject: `Ask an organizer inquiry from ${visitorEmail}`,
+ text: `Request ID: ${requestId}\nFrom: ${visitorEmail}\n\n${message}\n`,
+ });
+}
+
export async function sendEmailChangeEmail(
to: string,
verifyUrl: string,
diff --git a/lib/server/validation/organizerInquirySchemas.ts b/lib/server/validation/organizerInquirySchemas.ts
new file mode 100644
index 0000000..5d049c9
--- /dev/null
+++ b/lib/server/validation/organizerInquirySchemas.ts
@@ -0,0 +1,30 @@
+import { z } from "zod";
+import { ORGANIZER_INQUIRY_HONEYPOT_FIELD } from "../../organizerInquiryConstants";
+
+const emailSchema = z
+ .string()
+ .trim()
+ .min(1, "Email is required")
+ .max(254)
+ .transform((s) => s.toLowerCase())
+ .pipe(z.string().email("Enter a valid email address"));
+
+const messageSchema = z
+ .string()
+ .trim()
+ .min(10, "Please enter at least 10 characters")
+ .max(10_000, "Message is too long");
+
+/** Optional honeypot; non-empty after trim indicates a bot. */
+const honeypotSchema = z
+ .union([z.string(), z.undefined()])
+ .optional()
+ .transform((v) => (typeof v === "string" ? v.trim() : ""));
+
+export const organizerInquiryBodySchema = z.object({
+ email: emailSchema,
+ message: messageSchema,
+ [ORGANIZER_INQUIRY_HONEYPOT_FIELD]: honeypotSchema,
+});
+
+export type OrganizerInquiryBody = z.infer;
diff --git a/messages/en/components/askOrganizer.json b/messages/en/components/askOrganizer.json
index cfa8811..a91b1dc 100644
--- a/messages/en/components/askOrganizer.json
+++ b/messages/en/components/askOrganizer.json
@@ -1,4 +1,5 @@
{
"_comment": "AskOrganizer component defaults (shared across pages)",
- "ariaLabel": "Ask an organizer - Contact an organizer for help"
+ "ariaLabel": "Ask an organizer - Contact an organizer for help",
+ "buttonText": "Ask an organizer"
}
diff --git a/messages/en/index.ts b/messages/en/index.ts
index 09382e4..56a5a77 100644
--- a/messages/en/index.ts
+++ b/messages/en/index.ts
@@ -23,6 +23,7 @@ import navigation from "./navigation.json";
import metadata from "./metadata.json";
import modalsShare from "./modals/share.json";
import modalsPopoverExport from "./modals/popoverExport.json";
+import modalsAskOrganizerInquiry from "./modals/askOrganizerInquiry.json";
// create – stage 1: community
import createInformational from "./create/community/informational.json";
@@ -117,5 +118,6 @@ export default {
modals: {
share: modalsShare,
popoverExport: modalsPopoverExport,
+ askOrganizerInquiry: modalsAskOrganizerInquiry,
},
};
diff --git a/messages/en/modals/askOrganizerInquiry.json b/messages/en/modals/askOrganizerInquiry.json
new file mode 100644
index 0000000..0e012ec
--- /dev/null
+++ b/messages/en/modals/askOrganizerInquiry.json
@@ -0,0 +1,17 @@
+{
+ "_comment": "CR-107 Ask an organizer modal (Figma 22078-587823)",
+ "title": "Ask an Organizer",
+ "description": "Have a question about organizing? Send it over and an experienced organizer will get back to you.",
+ "emailLabel": "Email address",
+ "emailPlaceholder": "you@example.com",
+ "questionLabel": "Your question",
+ "questionPlaceholder": "What would you like to know?",
+ "submitButton": "Confirm Question",
+ "closeAfterSuccess": "Close",
+ "successTitle": "Thanks, we received your question",
+ "successDescription": "Check your inbox and an organizer will reply when they can.",
+ "genericError": "Something went wrong. Please try again.",
+ "rateLimitedError": "Too many attempts. Please wait a bit and try again.",
+ "ariaDialog": "Ask an organizer",
+ "honeypotLabel": "Company"
+}
diff --git a/messages/en/pages/home.json b/messages/en/pages/home.json
index c0c5a08..5a60d6c 100644
--- a/messages/en/pages/home.json
+++ b/messages/en/pages/home.json
@@ -53,8 +53,7 @@
"askOrganizer": {
"title": "Still have questions?",
"subtitle": "Get answers from an experienced organizer",
- "buttonText": "Ask an organizer",
- "buttonHref": "#contact"
+ "buttonText": "Ask an organizer"
},
"ruleStack": {
"title": "Popular templates",
diff --git a/messages/en/pages/learn.json b/messages/en/pages/learn.json
index caa28de..a721bc3 100644
--- a/messages/en/pages/learn.json
+++ b/messages/en/pages/learn.json
@@ -8,7 +8,6 @@
"title": "Still have questions?",
"subtitle": "Get answers from an experienced organizer",
"description": "Our community of organizers is here to help you navigate the challenges of building and maintaining effective community organizations.",
- "buttonText": "Ask an organizer",
- "buttonHref": "/contact"
+ "buttonText": "Ask an organizer"
}
}
diff --git a/stories/sections/AskOrganizer.stories.js b/stories/sections/AskOrganizer.stories.js
index 2e438ad..c30a974 100644
--- a/stories/sections/AskOrganizer.stories.js
+++ b/stories/sections/AskOrganizer.stories.js
@@ -49,7 +49,6 @@ export const Default = {
title: "Still have questions?",
subtitle: "Get answers from an experienced organizer",
buttonText: "Ask an organizer",
- buttonHref: "#contact",
variant: "centered",
onContactClick: (data) => console.log("Contact clicked:", data),
},
@@ -60,7 +59,6 @@ export const LeftAligned = {
title: "Still have questions?",
subtitle: "Get answers from an experienced organizer",
buttonText: "Ask an organizer",
- buttonHref: "#contact",
variant: "left-aligned",
onContactClick: (data) => console.log("Contact clicked:", data),
},
@@ -71,7 +69,6 @@ export const Compact = {
title: "Still have questions?",
subtitle: "Get answers from an experienced organizer",
buttonText: "Ask an organizer",
- buttonHref: "#contact",
variant: "compact",
onContactClick: (data) => console.log("Contact clicked:", data),
},
@@ -82,8 +79,19 @@ export const Inverse = {
title: "Still have questions?",
subtitle: "Get answers from an experienced organizer",
buttonText: "Ask an organizer",
- buttonHref: "#contact",
variant: "inverse",
onContactClick: (data) => console.log("Contact clicked:", data),
},
};
+
+/** Legacy: CTA is a link (no inquiry modal). */
+export const LinkCta = {
+ args: {
+ title: "Still have questions?",
+ subtitle: "Get answers from an experienced organizer",
+ buttonText: "Ask an organizer",
+ buttonHref: "/contact",
+ variant: "centered",
+ onContactClick: (data) => console.log("Contact clicked:", data),
+ },
+};
diff --git a/tests/components/AskOrganizer.test.tsx b/tests/components/AskOrganizer.test.tsx
index 0bf7c78..ab25b41 100644
--- a/tests/components/AskOrganizer.test.tsx
+++ b/tests/components/AskOrganizer.test.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import userEvent from "@testing-library/user-event";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import { describe, it, expect } from "vitest";
import AskOrganizer from "../../app/components/sections/AskOrganizer";
@@ -52,15 +53,24 @@ describe("AskOrganizer (behavioral tests)", () => {
).toBeInTheDocument();
});
- it("renders button with default text", () => {
+ it("renders CTA button with default label", () => {
render();
expect(
- screen.getByRole("link", {
+ screen.getByRole("button", {
name: /ask an organizer/i,
}),
).toBeInTheDocument();
});
+ it("opens inquiry modal when CTA is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByTestId("ask-organizer-cta"));
+ expect(
+ await screen.findByRole("dialog", { name: /ask an organizer/i }),
+ ).toBeInTheDocument();
+ });
+
it("renders button with custom text", () => {
render(
,
diff --git a/tests/e2e/critical-journeys.spec.ts b/tests/e2e/critical-journeys.spec.ts
index cc9f62e..5e2d58b 100644
--- a/tests/e2e/critical-journeys.spec.ts
+++ b/tests/e2e/critical-journeys.spec.ts
@@ -69,15 +69,13 @@ test.describe("Critical User Journeys", () => {
// 8. User reads testimonial
await expect(page.locator("text=Jo Freeman")).toBeVisible();
- // 9. User decides to contact organizer
- const askButton = page.locator(
- 'a:has-text("Ask an organizer"), button:has-text("Ask an organizer")',
- );
- if (
- (await askButton.count()) > 0 &&
- (await askButton.first().isVisible())
- ) {
- await askButton.first().click();
+ // 9. User decides to contact organizer (opens modal)
+ const askButton = page.getByTestId("ask-organizer-cta").first();
+ if ((await askButton.count()) > 0 && (await askButton.isVisible())) {
+ await askButton.click();
+ await expect(
+ page.getByRole("dialog", { name: /ask an organizer/i }),
+ ).toBeVisible();
}
});
diff --git a/tests/pages/page-flow.test.jsx b/tests/pages/page-flow.test.jsx
index ebc62bd..532e189 100644
--- a/tests/pages/page-flow.test.jsx
+++ b/tests/pages/page-flow.test.jsx
@@ -130,7 +130,7 @@ describe("Page Flow Integration", () => {
screen.getByText("Get answers from an experienced organizer"),
).toBeInTheDocument();
expect(
- screen.getByRole("link", { name: /Ask an organizer/i }),
+ screen.getByRole("button", { name: /ask an organizer/i }),
).toBeInTheDocument();
});
@@ -198,9 +198,9 @@ describe("Page Flow Integration", () => {
test("ask organizer section has proper call-to-action", () => {
render();
- const askLink = screen.getByRole("link", { name: /Ask an organizer/i });
- expect(askLink).toBeInTheDocument();
- expect(askLink).toHaveAttribute("href", "#contact");
+ const askCta = screen.getByRole("button", { name: /ask an organizer/i });
+ expect(askCta).toBeInTheDocument();
+ expect(askCta).not.toHaveAttribute("href");
});
test("page maintains proper semantic structure", async () => {
@@ -223,16 +223,22 @@ describe("Page Flow Integration", () => {
expect(mainContent).toBeInTheDocument();
});
- test("all interactive elements are accessible", () => {
+ test("all interactive elements are accessible", async () => {
render();
- // Check all buttons have proper roles
+ await waitFor(() => {
+ expect(screen.getAllByRole("button").length).toBeGreaterThan(0);
+ });
+
const buttons = screen.getAllByRole("button");
buttons.forEach((button) => {
expect(button).toBeInTheDocument();
});
- // Check all links have proper roles
+ await waitFor(() => {
+ expect(screen.getAllByRole("link").length).toBeGreaterThan(0);
+ });
+
const links = screen.getAllByRole("link");
links.forEach((link) => {
expect(link).toBeInTheDocument();
diff --git a/tests/pages/user-journey.test.jsx b/tests/pages/user-journey.test.jsx
index 324f79a..785c35a 100644
--- a/tests/pages/user-journey.test.jsx
+++ b/tests/pages/user-journey.test.jsx
@@ -121,10 +121,11 @@ describe("User Journey Integration", () => {
screen.getByText("Get answers from an experienced organizer"),
).toBeInTheDocument();
- // User clicks the ask organizer button (it's actually a link, not a button)
- const askLink = screen.getByRole("link", { name: /Ask an organizer/i });
- await user.click(askLink);
- expect(askLink).toHaveAttribute("href", "#contact");
+ const askCta = screen.getByTestId("ask-organizer-cta");
+ await user.click(askCta);
+ expect(
+ await screen.findByRole("dialog", { name: /ask an organizer/i }),
+ ).toBeInTheDocument();
});
test("user explores the process through CardSteps", async () => {
diff --git a/tests/unit/organizerInquiryPostRoute.test.ts b/tests/unit/organizerInquiryPostRoute.test.ts
new file mode 100644
index 0000000..a2aeda1
--- /dev/null
+++ b/tests/unit/organizerInquiryPostRoute.test.ts
@@ -0,0 +1,130 @@
+import { NextRequest } from "next/server";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const sendOrganizerInquiryNotificationMock = vi.fn();
+
+vi.mock("../../lib/server/mail", () => ({
+ sendOrganizerInquiryNotification: (...args: unknown[]) =>
+ sendOrganizerInquiryNotificationMock(...args),
+}));
+
+const rateLimitKeyMock = vi.hoisted(() =>
+ vi.fn(() => ({ ok: true as const })),
+);
+
+vi.mock("../../lib/server/rateLimit", () => ({
+ rateLimitKey: (...args: unknown[]) => rateLimitKeyMock(...args),
+}));
+
+import { POST } from "../../app/api/organizer-inquiry/route";
+
+describe("POST /api/organizer-inquiry", () => {
+ beforeEach(() => {
+ sendOrganizerInquiryNotificationMock.mockReset();
+ sendOrganizerInquiryNotificationMock.mockResolvedValue(undefined);
+ rateLimitKeyMock.mockReset();
+ rateLimitKeyMock.mockImplementation(() => ({ ok: true as const }));
+ process.env.ORGANIZER_INQUIRY_TO = "organizers@example.com";
+ });
+
+ afterEach(() => {
+ delete process.env.ORGANIZER_INQUIRY_TO;
+ });
+
+ it("returns 200 and sends mail for a valid payload", async () => {
+ const res = await POST(
+ new NextRequest("https://x.test/api/organizer-inquiry", {
+ method: "POST",
+ body: JSON.stringify({
+ email: "Visitor@Example.com",
+ message: "How do we run consensus meetings?",
+ company: "",
+ }),
+ }),
+ undefined,
+ );
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body).toEqual({ ok: true });
+ expect(sendOrganizerInquiryNotificationMock).toHaveBeenCalledTimes(1);
+ const arg = sendOrganizerInquiryNotificationMock.mock.calls[0][0];
+ expect(arg.visitorEmail).toBe("visitor@example.com");
+ expect(arg.message).toBe("How do we run consensus meetings?");
+ });
+
+ it("returns 200 without sending mail when honeypot is filled", async () => {
+ const res = await POST(
+ new NextRequest("https://x.test/api/organizer-inquiry", {
+ method: "POST",
+ body: JSON.stringify({
+ email: "spam@example.com",
+ message: "How do we run consensus meetings?",
+ company: "Evil Corp",
+ }),
+ }),
+ undefined,
+ );
+
+ expect(res.status).toBe(200);
+ expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
+ });
+
+ it("returns 400 for invalid email", async () => {
+ const res = await POST(
+ new NextRequest("https://x.test/api/organizer-inquiry", {
+ method: "POST",
+ body: JSON.stringify({
+ email: "not-an-email",
+ message: "How do we run consensus meetings?",
+ company: "",
+ }),
+ }),
+ undefined,
+ );
+
+ expect(res.status).toBe(400);
+ expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
+ });
+
+ it("returns 429 when rate limited", async () => {
+ rateLimitKeyMock.mockReturnValue({
+ ok: false as const,
+ retryAfterMs: 1000,
+ });
+
+ const res = await POST(
+ new NextRequest("https://x.test/api/organizer-inquiry", {
+ method: "POST",
+ body: JSON.stringify({
+ email: "a@b.co",
+ message: "How do we run consensus meetings?",
+ company: "",
+ }),
+ }),
+ undefined,
+ );
+
+ expect(res.status).toBe(429);
+ expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
+ });
+
+ it("returns 500 when ORGANIZER_INQUIRY_TO is unset", async () => {
+ delete process.env.ORGANIZER_INQUIRY_TO;
+
+ const res = await POST(
+ new NextRequest("https://x.test/api/organizer-inquiry", {
+ method: "POST",
+ body: JSON.stringify({
+ email: "a@b.co",
+ message: "How do we run consensus meetings?",
+ company: "",
+ }),
+ }),
+ undefined,
+ );
+
+ expect(res.status).toBe(500);
+ expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
+ });
+});
--
2.43.0