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) => ( -
-

{row.label}

- -
- )) + ? 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 */} + {caption.length 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( + `![${b.body.trim() || b.label}](${img})`, + "", + ); + } 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 += `

${escapeHtml(b.body.trim() || b.label)}

`; + 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 */} + {alt} + {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 */} - {caption.length 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( - `![${b.body.trim() || b.label}](${img})`, - "", - ); + lines.push(`![${bodyTrim || b.label}](${img})`, ""); } 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 += `

${escapeHtml(b.body.trim() || b.label)}

`; - if (b.body.trim().length > 0) { + inner += `

${escapeHtml(bodyTrim || b.label)}

`; + 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("![Caption](https://cdn.example.com/pic.jpg)"); + 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 ? ( + ) : null} + + + + ))} + + )} + +
+
+ { + setEmail(e.target.value); + setFieldError(""); + }} + error={Boolean(fieldError)} + textHint={fieldError || false} + autoComplete="email" + /> +
+ +
+ + ); +} diff --git a/app/(app)/create/types.ts b/app/(app)/create/types.ts index daaf124..e2a3a14 100644 --- a/app/(app)/create/types.ts +++ b/app/(app)/create/types.ts @@ -218,8 +218,11 @@ export interface CreateFlowState { currentStep?: CreateFlowStep; /** Section drafts; structure will tighten as steps persist real shapes. */ sections?: Record[]; - /** Stakeholder placeholders until the confirm-stakeholders step defines a schema. */ - stakeholders?: Record[]; + /** + * Stakeholder invite emails (confirm-stakeholders step). Normalized on the server; + * invites are sent at first publish (`POST /api/rules`). + */ + stakeholderEmails?: string[]; /** Extra step-specific fields (must be JSON-serializable for server draft sync). */ [key: string]: unknown; } diff --git a/app/(app)/create/utils/createFlowPaths.ts b/app/(app)/create/utils/createFlowPaths.ts index 8d5ba0f..9e2e0e6 100644 --- a/app/(app)/create/utils/createFlowPaths.ts +++ b/app/(app)/create/utils/createFlowPaths.ts @@ -4,7 +4,10 @@ */ import type { CreateFlowStep } from "../types"; -import { CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "./flowSteps"; +import { + CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY, + CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, +} from "./flowSteps"; export const CREATE_ROUTES = { root: "/", @@ -59,7 +62,7 @@ export function createCompletedPath(query?: CreateFlowPathQuery): string { /** * Navigate back from a facet step to final-review / edit-rule, dropping - * `reviewReturn` from the current query while preserving other params. + * `reviewReturn` and `manageStakeholders` from the current query while preserving other params. */ export function createFlowStepPathAfterStrippingReviewReturn( step: CreateFlowStep, @@ -67,6 +70,7 @@ export function createFlowStepPathAfterStrippingReviewReturn( ): string { const params = new URLSearchParams(searchParams?.toString() ?? ""); params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY); + params.delete(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY); const query: CreateFlowPathQuery = {}; params.forEach((value, key) => { query[key] = value; diff --git a/app/(app)/create/utils/flowSteps.ts b/app/(app)/create/utils/flowSteps.ts index cbe40aa..10cbab9 100644 --- a/app/(app)/create/utils/flowSteps.ts +++ b/app/(app)/create/utils/flowSteps.ts @@ -188,6 +188,13 @@ export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const; /** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */ export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const; +/** + * `/create/confirm-stakeholders?manageStakeholders=1` — edit published rule invites (requires `state.editingPublishedRuleId`). + * Typically paired with `reviewReturn=edit-rule`. + */ +export const CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY = "manageStakeholders" as const; +export const CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE = "1" as const; + export type CreateFlowReviewReturnTarget = "final-review" | "edit-rule"; export function parseReviewReturnSearchParam( diff --git a/app/(app)/profile/_components/ProfilePage.view.tsx b/app/(app)/profile/_components/ProfilePage.view.tsx index b485643..49e75e8 100644 --- a/app/(app)/profile/_components/ProfilePage.view.tsx +++ b/app/(app)/profile/_components/ProfilePage.view.tsx @@ -340,28 +340,38 @@ export function ProfilePageView({ expanded size={ruleCardSize} hasBottomLinks - bottomLinks={[ - { - id: "view", - label: t("viewPublic"), - href: `/rules/${encodeURIComponent(rule.id)}`, - }, - { - id: "manage", - label: t("manageRule"), - href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`, - }, - { - id: "dup", - label: t("duplicate"), - onClick: () => onDuplicateRule(rule.id), - }, - { - id: "del", - label: t("deleteRule"), - onClick: () => onDeleteRule(rule.id), - }, - ]} + bottomLinks={ + rule.role === "stakeholder" + ? [ + { + id: "view", + label: t("viewPublic"), + href: `/rules/${encodeURIComponent(rule.id)}`, + }, + ] + : [ + { + id: "view", + label: t("viewPublic"), + href: `/rules/${encodeURIComponent(rule.id)}`, + }, + { + id: "manage", + label: t("manageRule"), + href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`, + }, + { + id: "dup", + label: t("duplicate"), + onClick: () => onDuplicateRule(rule.id), + }, + { + id: "del", + label: t("deleteRule"), + onClick: () => onDeleteRule(rule.id), + }, + ] + } communityInitials={ rule.title.trim().charAt(0).toUpperCase() || "·" } diff --git a/app/api/invites/rule-stakeholder/verify/route.ts b/app/api/invites/rule-stakeholder/verify/route.ts new file mode 100644 index 0000000..ed63b04 --- /dev/null +++ b/app/api/invites/rule-stakeholder/verify/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "../../../../../lib/server/db"; +import { + getSessionPepper, + isDatabaseConfigured, +} from "../../../../../lib/server/env"; +import { hashSessionToken } from "../../../../../lib/server/hash"; +import { + REQUEST_ID_HEADER, + getOrCreateRequestId, + logRouteError, +} from "../../../../../lib/server/requestId"; +import { dbUnavailable } from "../../../../../lib/server/responses"; +import { + createSessionForUser, + getSessionUser, + setSessionCookie, +} from "../../../../../lib/server/session"; + +const SCOPE = "invites.ruleStakeholder.verify"; + +export async function GET(request: NextRequest) { + const requestId = getOrCreateRequestId(request); + + if (!isDatabaseConfigured()) { + const res = dbUnavailable(); + res.headers.set(REQUEST_ID_HEADER, requestId); + return res; + } + + try { + const token = request.nextUrl.searchParams.get("token"); + if (!token || token.length < 10) { + return redirectWithRequestId( + request, + "/login?error=invalid_link", + requestId, + ); + } + + let pepper: string; + try { + pepper = getSessionPepper(); + } catch (err) { + logRouteError(SCOPE, requestId, err, { phase: "getSessionPepper" }); + return redirectWithRequestId(request, "/login?error=server", requestId); + } + + const tokenHash = hashSessionToken(token, pepper); + + const row = await prisma.ruleStakeholder.findUnique({ + where: { inviteTokenHash: tokenHash }, + select: { + id: true, + email: true, + ruleId: true, + inviteExpiresAt: true, + }, + }); + + if ( + !row || + !row.inviteExpiresAt || + row.inviteExpiresAt < new Date() + ) { + return redirectWithRequestId( + request, + "/login?error=expired_link", + requestId, + ); + } + + const existingSession = await getSessionUser(); + if ( + existingSession && + existingSession.email.trim().toLowerCase() !== row.email + ) { + return redirectWithRequestId( + request, + "/login?error=stakeholder_wrong_account", + requestId, + ); + } + + const user = await prisma.user.upsert({ + where: { email: row.email }, + create: { email: row.email }, + update: {}, + }); + + await prisma.ruleStakeholder.update({ + where: { id: row.id }, + data: { + userId: user.id, + acceptedAt: new Date(), + inviteTokenHash: null, + inviteExpiresAt: null, + }, + }); + + const { token: sessionToken, expiresAt } = await createSessionForUser( + user.id, + ); + await setSessionCookie(sessionToken, expiresAt); + + const dest = `/rules/${encodeURIComponent(row.ruleId)}`; + return redirectWithRequestId(request, dest, requestId); + } catch (err) { + logRouteError(SCOPE, requestId, err); + return redirectWithRequestId(request, "/login?error=server", requestId); + } +} + +function redirectWithRequestId( + request: NextRequest, + path: string, + requestId: string, +): NextResponse { + const res = NextResponse.redirect(new URL(path, request.url)); + res.headers.set(REQUEST_ID_HEADER, requestId); + return res; +} diff --git a/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts b/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts new file mode 100644 index 0000000..87fcc3e --- /dev/null +++ b/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "../../../../../../../lib/server/db"; +import { + getSessionPepper, + isDatabaseConfigured, +} from "../../../../../../../lib/server/env"; +import { hashSessionToken, newSessionToken } from "../../../../../../../lib/server/hash"; +import { sendRuleStakeholderInviteEmail } from "../../../../../../../lib/server/mail"; +import { apiRoute } from "../../../../../../../lib/server/apiRoute"; +import { logRouteError } from "../../../../../../../lib/server/requestId"; +import { stakeholderInviteVerifyUrl } from "../../../../../../../lib/server/ruleStakeholderInviteOps"; +import { STAKEHOLDER_INVITE_TTL_MS } from "../../../../../../../lib/server/ruleStakeholders"; +import { + dbUnavailable, + errorJson, + forbidden, + notFound, + rateLimited, + serverMisconfigured, + unauthorized, +} from "../../../../../../../lib/server/responses"; +import { getSessionUser } from "../../../../../../../lib/server/session"; +import { rateLimitKey } from "../../../../../../../lib/server/rateLimit"; + +type RouteContext = { params: Promise<{ id: string; stakeholderId: string }> }; + +export const POST = apiRoute( + "rules.stakeholders.resend", + async (request: NextRequest, context, { requestId }) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const user = await getSessionUser(); + if (!user) { + return unauthorized(); + } + + const { id: ruleId, stakeholderId } = await context.params; + + const row = await prisma.ruleStakeholder.findFirst({ + where: { id: stakeholderId, ruleId }, + select: { + id: true, + email: true, + inviteTokenHash: true, + inviteExpiresAt: true, + rule: { select: { userId: true, title: true } }, + }, + }); + + if (!row) { + return notFound(); + } + if (row.rule.userId !== user.id) { + return forbidden(); + } + if (row.inviteTokenHash === null) { + return errorJson( + "validation_error", + "This stakeholder has already accepted the invite", + 400, + ); + } + + const rl = rateLimitKey(`rule-stakeholders-resend:${row.id}`, 60_000); + if (rl.ok === false) { + return rateLimited(rl.retryAfterMs); + } + + let pepper: string; + try { + pepper = getSessionPepper(); + } catch (err) { + logRouteError("rules.stakeholders.resend", requestId, err, { + phase: "getSessionPepper", + }); + return serverMisconfigured(); + } + + const prevHash = row.inviteTokenHash; + const prevExp = row.inviteExpiresAt; + const token = newSessionToken(); + const newHash = hashSessionToken(token, pepper); + const newExp = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS); + + await prisma.ruleStakeholder.update({ + where: { id: row.id }, + data: { + inviteTokenHash: newHash, + inviteExpiresAt: newExp, + }, + }); + + const verifyUrl = stakeholderInviteVerifyUrl(request.nextUrl.origin, token); + try { + await sendRuleStakeholderInviteEmail(row.email, verifyUrl, row.rule.title); + } catch (err) { + logRouteError("rules.stakeholders.resend", requestId, err, { + phase: "sendRuleStakeholderInviteEmail", + }); + await prisma.ruleStakeholder + .update({ + where: { id: row.id }, + data: { + inviteTokenHash: prevHash, + inviteExpiresAt: prevExp, + }, + }) + .catch(() => {}); + return errorJson( + "mail_failed", + "Could not resend stakeholder invite", + 502, + ); + } + + return NextResponse.json({ ok: true }); + }, +); diff --git a/app/api/rules/[id]/stakeholders/[stakeholderId]/route.ts b/app/api/rules/[id]/stakeholders/[stakeholderId]/route.ts new file mode 100644 index 0000000..51adcdd --- /dev/null +++ b/app/api/rules/[id]/stakeholders/[stakeholderId]/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "../../../../../../lib/server/db"; +import { isDatabaseConfigured } from "../../../../../../lib/server/env"; +import { apiRoute } from "../../../../../../lib/server/apiRoute"; +import { + dbUnavailable, + forbidden, + notFound, + unauthorized, +} from "../../../../../../lib/server/responses"; +import { getSessionUser } from "../../../../../../lib/server/session"; + +type RouteContext = { params: Promise<{ id: string; stakeholderId: string }> }; + +export const DELETE = apiRoute( + "rules.stakeholders.delete", + async (_request: NextRequest, context) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const user = await getSessionUser(); + if (!user) { + return unauthorized(); + } + + const { id: ruleId, stakeholderId } = await context.params; + + const row = await prisma.ruleStakeholder.findFirst({ + where: { id: stakeholderId, ruleId }, + select: { + id: true, + rule: { select: { userId: true } }, + }, + }); + + if (!row) { + return notFound(); + } + if (row.rule.userId !== user.id) { + return forbidden(); + } + + await prisma.ruleStakeholder.delete({ where: { id: row.id } }); + + return NextResponse.json({ ok: true }); + }, +); diff --git a/app/api/rules/[id]/stakeholders/route.ts b/app/api/rules/[id]/stakeholders/route.ts new file mode 100644 index 0000000..f169cb2 --- /dev/null +++ b/app/api/rules/[id]/stakeholders/route.ts @@ -0,0 +1,192 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "../../../../../lib/server/db"; +import { + getSessionPepper, + isDatabaseConfigured, +} from "../../../../../lib/server/env"; +import { rateLimitKey } from "../../../../../lib/server/rateLimit"; +import { apiRoute } from "../../../../../lib/server/apiRoute"; +import { logRouteError } from "../../../../../lib/server/requestId"; +import { createRuleStakeholderInviteAndSendMail } from "../../../../../lib/server/ruleStakeholderInviteOps"; +import { + conflict, + dbUnavailable, + errorJson, + notFound, + rateLimited, + serverMisconfigured, + unauthorized, +} from "../../../../../lib/server/responses"; +import { getSessionUser } from "../../../../../lib/server/session"; +import { + MAX_STAKEHOLDER_EMAILS, + postRuleStakeholderBodySchema, +} from "../../../../../lib/server/validation/createFlowSchemas"; +import { readLimitedJson } from "../../../../../lib/server/validation/requestBody"; +import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp"; + +type RouteContext = { params: Promise<{ id: string }> }; + +async function ownedRuleMeta(ruleId: string, userId: string) { + return prisma.publishedRule.findFirst({ + where: { id: ruleId, userId }, + select: { id: true, title: true }, + }); +} + +export const GET = apiRoute( + "rules.stakeholders.list", + async (_request, context) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const user = await getSessionUser(); + if (!user) { + return unauthorized(); + } + + const { id: ruleId } = await context.params; + const rule = await ownedRuleMeta(ruleId, user.id); + if (!rule) { + return notFound(); + } + + const rows = await prisma.ruleStakeholder.findMany({ + where: { ruleId: rule.id }, + orderBy: [{ invitedAt: "asc" }, { id: "asc" }], + select: { + id: true, + email: true, + invitedAt: true, + acceptedAt: true, + inviteTokenHash: true, + }, + }); + + return NextResponse.json({ + stakeholders: rows.map((r) => ({ + id: r.id, + email: r.email, + invitedAt: r.invitedAt.toISOString(), + acceptedAt: r.acceptedAt?.toISOString() ?? null, + status: + r.inviteTokenHash !== null ? ("pending" as const) : ("accepted" as const), + })), + }); + }, +); + +export const POST = apiRoute( + "rules.stakeholders.add", + async (request: NextRequest, context, { requestId }) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const user = await getSessionUser(); + if (!user) { + return unauthorized(); + } + + const { id: ruleId } = await context.params; + const rule = await ownedRuleMeta(ruleId, user.id); + if (!rule) { + return notFound(); + } + + const parsedBody = await readLimitedJson(request); + if (parsedBody.ok === false) { + return parsedBody.response; + } + + const validated = postRuleStakeholderBodySchema.safeParse(parsedBody.value); + if (!validated.success) { + return jsonFromZodError(validated.error); + } + + const email = validated.data.email; + if (email === user.email.trim().toLowerCase()) { + return errorJson( + "validation_error", + "You cannot invite your own account email", + 400, + ); + } + + const existing = await prisma.ruleStakeholder.findFirst({ + where: { ruleId: rule.id, email }, + }); + if (existing) { + return conflict("That email is already invited for this rule"); + } + + const count = await prisma.ruleStakeholder.count({ + where: { ruleId: rule.id }, + }); + if (count >= MAX_STAKEHOLDER_EMAILS) { + return errorJson( + "validation_error", + `You can invite at most ${MAX_STAKEHOLDER_EMAILS} stakeholders per rule`, + 400, + ); + } + + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + request.headers.get("x-real-ip") ?? + "unknown"; + const rl = rateLimitKey(`rule-stakeholders-add-ip:${ip}`, 60_000); + if (rl.ok === false) { + return rateLimited(rl.retryAfterMs); + } + + let pepper: string; + try { + pepper = getSessionPepper(); + } catch (err) { + logRouteError("rules.stakeholders.add", requestId, err, { + phase: "getSessionPepper", + }); + return serverMisconfigured(); + } + + const origin = request.nextUrl.origin; + const sent = await createRuleStakeholderInviteAndSendMail({ + scope: "rules.stakeholders.add", + requestId, + origin, + ruleId: rule.id, + ruleTitle: rule.title, + email, + invitedByUserId: user.id, + pepper, + }); + + if (!sent.ok) { + return errorJson( + "mail_failed", + "Could not send stakeholder invite", + 502, + ); + } + + const created = await prisma.ruleStakeholder.findFirst({ + where: { ruleId: rule.id, email }, + select: { id: true, email: true, invitedAt: true }, + }); + + return NextResponse.json( + { + stakeholder: created && { + id: created.id, + email: created.email, + invitedAt: created.invitedAt.toISOString(), + acceptedAt: null, + status: "pending" as const, + }, + }, + { status: 201 }, + ); + }, +); diff --git a/app/api/rules/me/route.ts b/app/api/rules/me/route.ts index 69d5e8f..06a90d5 100644 --- a/app/api/rules/me/route.ts +++ b/app/api/rules/me/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { isDatabaseConfigured } from "../../../../lib/server/env"; -import { listPublishedRulesForUser } from "../../../../lib/server/publishedRules"; +import { listProfileRulesForUser } from "../../../../lib/server/publishedRules"; import { dbUnavailable, internalError, @@ -22,10 +22,19 @@ export const GET = apiRoute("rules.me.list", async (request: NextRequest) => { const { searchParams } = new URL(request.url); const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100); - const rules = await listPublishedRulesForUser(user.id, take); + const rules = await listProfileRulesForUser(user.id, take); if (rules === null) { return internalError("Failed to list rules"); } - return NextResponse.json({ rules }); + return NextResponse.json({ + rules: rules.map((r) => ({ + id: r.id, + title: r.title, + summary: r.summary, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + role: r.role, + })), + }); }); diff --git a/app/api/rules/route.ts b/app/api/rules/route.ts index 35ad450..2b0983b 100644 --- a/app/api/rules/route.ts +++ b/app/api/rules/route.ts @@ -1,14 +1,29 @@ import type { Prisma } from "@prisma/client"; import { NextRequest, NextResponse } from "next/server"; import { prisma } from "../../../lib/server/db"; -import { isDatabaseConfigured } from "../../../lib/server/env"; +import { getSessionPepper, isDatabaseConfigured } from "../../../lib/server/env"; +import { + hashSessionToken, + newSessionToken, +} from "../../../lib/server/hash"; +import { sendRuleStakeholderInviteEmail } from "../../../lib/server/mail"; +import { rateLimitKey } from "../../../lib/server/rateLimit"; import { dbUnavailable, + errorJson, + rateLimited, + serverMisconfigured, unauthorized, } from "../../../lib/server/responses"; +import { logRouteError } from "../../../lib/server/requestId"; +import { stakeholderInviteVerifyUrl } from "../../../lib/server/ruleStakeholderInviteOps"; +import { STAKEHOLDER_INVITE_TTL_MS } from "../../../lib/server/ruleStakeholders"; import { getSessionUser } from "../../../lib/server/session"; import { apiRoute } from "../../../lib/server/apiRoute"; -import { publishRuleBodySchema } from "../../../lib/server/validation/createFlowSchemas"; +import { + publishRuleBodySchema, + uniqueStakeholderEmailsForPublish, +} from "../../../lib/server/validation/createFlowSchemas"; import { readLimitedJson } from "../../../lib/server/validation/requestBody"; import { jsonFromZodError } from "../../../lib/server/validation/zodHttp"; @@ -36,43 +51,134 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => { return NextResponse.json({ rules }); }); -export const POST = apiRoute("rules.publish", async (request: NextRequest) => { - if (!isDatabaseConfigured()) { - return dbUnavailable(); - } +export const POST = apiRoute( + "rules.publish", + async (request: NextRequest, _ctx, { requestId }) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } - const user = await getSessionUser(); - if (!user) { - return unauthorized(); - } + const user = await getSessionUser(); + if (!user) { + return unauthorized(); + } - const parsedBody = await readLimitedJson(request); - if (parsedBody.ok === false) { - return parsedBody.response; - } + const parsedBody = await readLimitedJson(request); + if (parsedBody.ok === false) { + return parsedBody.response; + } - const validated = publishRuleBodySchema.safeParse(parsedBody.value); - if (!validated.success) { - return jsonFromZodError(validated.error); - } + const validated = publishRuleBodySchema.safeParse(parsedBody.value); + if (!validated.success) { + return jsonFromZodError(validated.error); + } - const { title, summary, document } = validated.data; + const { title, summary, document, stakeholderEmails } = validated.data; + const inviteEmails = uniqueStakeholderEmailsForPublish( + stakeholderEmails, + user.email, + ); - const rule = await prisma.publishedRule.create({ - data: { - userId: user.id, - title, - summary, - document: document as Prisma.InputJsonValue, - }, - }); + if (inviteEmails.length > 0) { + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + request.headers.get("x-real-ip") ?? + "unknown"; + const rl = rateLimitKey(`publish-stakeholders-ip:${ip}`, 60_000); + if (rl.ok === false) { + return rateLimited(rl.retryAfterMs); + } + } - return NextResponse.json({ - rule: { - id: rule.id, - title: rule.title, - summary: rule.summary, - createdAt: rule.createdAt, - }, - }); -}); + if (inviteEmails.length === 0) { + const rule = await prisma.publishedRule.create({ + data: { + userId: user.id, + title, + summary, + document: document as Prisma.InputJsonValue, + }, + }); + + return NextResponse.json({ + rule: { + id: rule.id, + title: rule.title, + summary: rule.summary, + createdAt: rule.createdAt, + }, + }); + } + + let pepper: string; + try { + pepper = getSessionPepper(); + } catch (err) { + logRouteError("rules.publish", requestId, err, { + phase: "getSessionPepper", + }); + return serverMisconfigured(); + } + + const expiresAt = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS); + const { rule, invites } = await prisma.$transaction(async (tx) => { + const created = await tx.publishedRule.create({ + data: { + userId: user.id, + title, + summary, + document: document as Prisma.InputJsonValue, + }, + }); + const toSend: { email: string; token: string }[] = []; + for (const email of inviteEmails) { + const token = newSessionToken(); + const tokenHash = hashSessionToken(token, pepper); + await tx.ruleStakeholder.create({ + data: { + ruleId: created.id, + email, + invitedByUserId: user.id, + inviteTokenHash: tokenHash, + inviteExpiresAt: expiresAt, + }, + }); + toSend.push({ email, token }); + } + return { rule: created, invites: toSend }; + }); + + const origin = request.nextUrl.origin; + try { + for (const inv of invites) { + const verifyUrl = stakeholderInviteVerifyUrl(origin, inv.token); + await sendRuleStakeholderInviteEmail(inv.email, verifyUrl, title); + } + } catch (err) { + logRouteError("rules.publish", requestId, err, { + phase: "sendRuleStakeholderInviteEmail", + }); + try { + await prisma.publishedRule.delete({ where: { id: rule.id } }); + } catch (delErr) { + logRouteError("rules.publish", requestId, delErr, { + phase: "rollbackPublishAfterMailFailure", + }); + } + return errorJson( + "mail_failed", + "Could not send stakeholder invites", + 502, + ); + } + + return NextResponse.json({ + rule: { + id: rule.id, + title: rule.title, + summary: rule.summary, + createdAt: rule.createdAt, + }, + }); + }, +); diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx index 02abeb4..e99f9b3 100644 --- a/app/components/modals/Login/LoginForm.tsx +++ b/app/components/modals/Login/LoginForm.tsx @@ -125,11 +125,13 @@ export default function LoginForm({ const urlErrorMessage = errorParam === "expired_link" ? t("errors.expiredLink") - : errorParam === "invalid_link" || errorParam === "server" - ? errorParam === "server" - ? t("errors.serverError") - : t("errors.invalidLink") - : ""; + : errorParam === "stakeholder_wrong_account" + ? t("errors.stakeholderWrongAccount") + : errorParam === "invalid_link" || errorParam === "server" + ? errorParam === "server" + ? t("errors.serverError") + : t("errors.invalidLink") + : ""; const titleId = "login-modal-heading"; diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx index f09e5d5..74d4f16 100644 --- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx +++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx @@ -16,10 +16,12 @@ const CreateFlowTopNavContainer = memo( hasShare = false, hasExport = false, hasEdit = false, + hasManageStakeholders = false, saveDraftOnExit = false, onShare, onSelectExportFormat, onEdit, + onManageStakeholders, onExit, buttonPalette, className = "", @@ -41,10 +43,12 @@ const CreateFlowTopNavContainer = memo( hasShare={hasShare} hasExport={hasExport} hasEdit={hasEdit} + hasManageStakeholders={hasManageStakeholders} saveDraftOnExit={saveDraftOnExit} onShare={onShare} onSelectExportFormat={onSelectExportFormat} onEdit={onEdit} + onManageStakeholders={onManageStakeholders} onExit={handleExit} buttonPalette={buttonPalette} className={className} diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts index 6589855..0bf91b0 100644 --- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts +++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts @@ -21,6 +21,12 @@ export interface CreateFlowTopNavProps { * @default false */ hasEdit?: boolean; + /** + * Whether to show **Manage Stakeholders** (published-rule invite management). + * Used on `/create/edit-rule` only. + * @default false + */ + hasManageStakeholders?: boolean; /** * When true, exit control is "Save & Exit" and `onExit` receives `{ saveDraft: true }`. * When false, shows "Exit" and `{ saveDraft: false }` (caller may confirm data loss). @@ -39,6 +45,10 @@ export interface CreateFlowTopNavProps { * Callback when Edit button is clicked */ onEdit?: () => void; + /** + * Callback when Manage Stakeholders is clicked + */ + onManageStakeholders?: () => void; /** * Callback when Exit/Save & Exit button is clicked. * When `saveDraftOnExit` is true, called with `{ saveDraft: true }`. diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx index 1498450..a78071f 100644 --- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx +++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx @@ -15,10 +15,12 @@ export function CreateFlowTopNavView({ hasShare = false, hasExport = false, hasEdit = false, + hasManageStakeholders = false, saveDraftOnExit = false, onShare, onSelectExportFormat, onEdit, + onManageStakeholders, onExit, buttonPalette = "default", className = "", @@ -165,6 +167,20 @@ export function CreateFlowTopNavView({ )} + {hasManageStakeholders && onManageStakeholders ? ( + + ) : null} + + + ) : ( +
+ +
+ ); + + return ( + + {success ? ( +
+

+ {t("successTitle")} +

+

+ {t("successDescription")} +

+
+ ) : ( +
+ {formError ? ( +

+ {formError} +

+ ) : null} + + onEmailChange(e.target.value)} + error={emailError} + inputSize="medium" + showHelpIcon={false} + /> + +