feat: Decision Export System with Multiple Formats#3
Conversation
## What Was Built A comprehensive Decision Export System for PM Analyzer that enables product managers to export decisions in multiple formats for different downstream tools. ### Components Created 1. **DecisionExportDialog.tsx** - A reusable export modal component - 7 export formats: Cursor/Claude Code, Linear, Jira, Slack, Notion, Markdown, JSON - Live preview of export format - Copy to clipboard functionality - Download as file 2. **Export API Route** (`/api/projects/[id]/decisions/[decisionId]/export`) - GET endpoint for programmatic export - Supports all 7 formats - Returns appropriate Content-Type headers 3. **Decision Detail Page** (`/projects/[id]/decisions/[decisionId]`) - Full decision view with all metadata - Embedded Export dialog - Confidence score badges - Linked feedback display 4. **Decisions List Page** (`/projects/[id]/decisions`) - All decisions in one place - Export button on each decision card - Quick filtering by status ### Export Formats | Format | Use Case | |--------|----------| | Cursor/Claude Code | AI pair programming handoff with structured prompts | | Linear | Issue tracker format for sprint planning | | Jira | Enterprise issue tracking | | Slack | Stakeholder communication with emoji confidence | | Notion | Documentation and wiki export | | Markdown | General documentation | | JSON | Programmatic access and integration | ### Why This Matters Product managers need to hand off decisions to: - Engineering teams (Linear/Jira) - AI coding assistants (Cursor) - Stakeholders (Slack/Notion) - Documentation systems (Markdown/JSON) This feature bridges the gap between PM Analyzer's decision-making capabilities and the tools teams actually use to build software. --- **Nightly Build (2026-02-07)**
📝 WalkthroughWalkthroughThis pull request introduces decision export functionality to the application. It adds a new API route handler for exporting decisions in multiple formats, two new pages for viewing decisions (list and detail views), and a reusable DecisionExportDialog component supporting seven export formats (Cursor, Linear, Jira, Slack, Notion, Markdown, and JSON). Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant UI as DecisionExportDialog
participant API as /api/.../export
participant DB as Database
User->>UI: Select export format
Note over UI: Generate preview<br/>(client-side)
UI-->>User: Display preview
User->>UI: Click Download
UI->>API: GET /export?format=markdown
API->>DB: Fetch decision by ID
DB-->>API: Decision data
API->>API: formatDecision(data, format)
Note over API: Apply format-specific<br/>transformation
API-->>UI: Formatted content +<br/>Content-Disposition header
UI-->>User: Download file
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 17596b3c02
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| import { | ||
| formatDecision, | ||
| ExportFormat, | ||
| DecisionExportData, | ||
| } from "@/components/DecisionExportDialog"; |
There was a problem hiding this comment.
Move export formatting logic out of client module
This API route imports formatDecision from components/DecisionExportDialog.tsx, but that file is explicitly marked with "use client"; server handlers cannot safely execute client-module exports, so calling formatDecision here can fail the export endpoint at runtime/build time instead of returning content. Extract the formatter/types into a shared server-safe utility and import that utility from both the dialog and this route.
Useful? React with 👍 / 👎.
| const handleExport = (format: ExportFormat) => { | ||
| const content = updatePreview(format); | ||
| if (onExport) { | ||
| onExport(format, content); | ||
| } |
There was a problem hiding this comment.
Implement actual download when no onExport callback is passed
The Download action only regenerates preview text and conditionally calls onExport; there is no fallback that writes a blob or triggers a file download. In this commit both call sites render DecisionExportDialog without an onExport prop, so clicking "Download" does nothing for users on the decisions list/detail pages.
Useful? React with 👍 / 👎.
| Filter | ||
| </Button> | ||
| <Button asChild size="sm"> | ||
| <Link href={`/projects/${projectId}/decisions/new`}> |
There was a problem hiding this comment.
Link New Decision button to an existing route
This primary CTA points to /projects/${projectId}/decisions/new, but this change only adds /decisions/page.tsx and /decisions/[decisionId]/page.tsx under that segment, so "new" resolves as a decisionId and leads to a not-found page. As shipped, users are sent to a dead-end when trying to create a decision from this screen.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Fix all issues with AI agents
In `@app/api/projects/`[id]/decisions/[decisionId]/export/route.ts:
- Around line 86-88: The filename creation uses raw user input (decision.title)
and must be sanitized before putting it in the Content-Disposition header;
update the code that builds filename (the filename const in route.ts) to (1)
normalize and trim the title, (2) remove or replace control characters like
newlines and quotes and any path separators, (3) restrict to a safe subset
(e.g., convert to ASCII-like slug with only letters, numbers, hyphens, and
underscores, collapsing consecutive non-safe chars), and (4) provide a safe
fallback (e.g., "decision") if the result is empty; finally ensure the header
uses an encoded filename fallback (RFC5987 / encodeURIComponent) when setting
Content-Disposition so non-ASCII characters are handled safely.
In `@app/projects/`[id]/decisions/[decisionId]/page.tsx:
- Line 62: The code uses decision.status.replace("_", " ") which only replaces
the first underscore; update the rendering logic that references
decision.status.replace to replace all underscores (e.g., use a global
replacement or split/join) so multi-underscore statuses like
"in_progress_review" become "in progress review". Ensure this change is applied
where decision.status.replace is used in the page.tsx render.
In `@app/projects/`[id]/decisions/page.tsx:
- Around line 54-57: The Filter button (the Button element rendering the Filter
icon in page.tsx) is non-functional; add an onClick handler to either toggle a
local filter UI state (e.g., a useState like showFilters and a handler such as
handleToggleFilters) or call the existing filter-opening function if one exists,
and ensure the Button's onClick wires to that handler (or remove the Button
entirely if filtering is not implemented) so the control is not misleading to
users.
In `@components/DecisionExportDialog.tsx`:
- Around line 380-381: The custom modal in DecisionExportDialog (the
isOpen-controlled overlay div) lacks standard accessibility: replace the
hand-rolled overlay and content with the Radix/Dialog (or the project's Dialog
primitive from components/ui) so you get built-in focus trap, Escape handling,
and return-focus behavior; ensure the dialog root is controlled by the existing
isOpen state and calls the component's onClose/close handler when closed, and
add aria attributes (role="dialog" / aria-modal="true" and aria-labelledby
pointing to the dialog title element) for the dialog content that previously
lived inside the fixed inset div.
- Around line 363-364: The custom trigger wrapper currently only does
setIsOpen(true) which both fails to call updatePreview (so the preview is empty)
and creates accessibility issues by using a plain <div>; change the trigger
handling to invoke updatePreview() before opening (call updatePreview() then
setIsOpen(true)) and make the trigger keyboard-accessible by forwarding proper
button semantics instead of a bare div — e.g., use React.cloneElement(trigger, {
onClick: () => { updatePreview(); setIsOpen(true); }, onKeyDown: (e) => { if
(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); updatePreview();
setIsOpen(true); } }, role: 'button', tabIndex: 0 }) so the custom trigger
receives click/keyboard handlers, role="button" and tabIndex to match how the
default Button path triggers updatePreview and supports keyboard interaction.
- Around line 456-462: The Download button currently calls
handleExport(selectedFormat) which only invokes the optional onExport callback
and does nothing when onExport is not provided; implement fallback download
logic inside handleExport in DecisionExportDialog: when onExport is undefined,
generate the export payload (based on selectedFormat and existing export
data/helpers in the component), create a Blob, create an object URL,
programmatically create and click an anchor (<a>) with download attribute, and
revoke the URL after use so the file actually downloads; keep the existing
onExport call path intact and only run the Blob/link code as the fallback.
🧹 Nitpick comments (5)
app/api/projects/[id]/decisions/[decisionId]/export/route.ts (3)
3-7: Importing formatting utilities from a"use client"component into a server route is an architectural smell.The formatter functions (
formatDecision, etc.) are pure logic with no React/browser dependencies. They should live in a shared utility module (e.g.,lib/export-formatters.ts) and be imported by both the client component and the API route. This avoids coupling server code to client component boundaries and makes the formatters independently testable.
16-16: Unsafe type assertion before validation.The
as ExportFormatcast on an unvalidated query param defeats TypeScript's type safety. The validation on line 28 catches invalid values at runtime, but between lines 16–28 the variable is incorrectly typed.Proposed fix
- const format = (searchParams.get("format") as ExportFormat) || "markdown"; + const format = searchParams.get("format") || "markdown"; // Validate format const validFormats = [ "cursor", "linear", "jira", "slack", "notion", "markdown", "json", - ]; - if (!validFormats.includes(format)) { + ] as const; + if (!validFormats.includes(format as ExportFormat)) { return NextResponse.json( { error: `Invalid format. Valid formats: ${validFormats.join(", ")}` }, { status: 400 } ); } + const validatedFormat = format as ExportFormat;Then use
validatedFormatin subsequent code.
75-81: Double JSON serialize/parse is unnecessary.
formatAsJsonreturnsJSON.stringify(...), then line 76 doesJSON.parse(content)only to haveNextResponse.json()re-stringify it. You can return the string directly.Proposed fix
if (format === "json") { - return NextResponse.json(JSON.parse(content), { - headers: { - "Content-Type": "application/json", - }, + return new NextResponse(content, { + headers: { + "Content-Type": "application/json", + }, }); }app/projects/[id]/decisions/page.tsx (1)
86-97:DecisionExportDataconstruction is duplicated across three files.The same field mapping from a Prisma decision to
DecisionExportDataappears here, in the detail page, and in the API route. Extract a helper (e.g.,toDecisionExportData(decision, projectName)) into a shared module to reduce duplication and prevent drift.Also applies to: 57-68
components/DecisionExportDialog.tsx (1)
279-298: JSON export omitsprojectNameandlinkedFeedbackIdscount context.
formatAsJsonhardcodesstatus: "ready_for_handoff"and omitsprojectNamefrom the output. Other formatters include project name when available. If the JSON format is meant for programmatic consumption, including all available fields would be more useful.
| const filename = `decision-${decision.title | ||
| .toLowerCase() | ||
| .replace(/\s+/g, "-")}.${format === "markdown" ? "md" : format}`; |
There was a problem hiding this comment.
Decision title in filename is not sanitized — potential header injection or broken downloads.
decision.title is user-controlled content used directly in the Content-Disposition header. Titles with characters like ", \n, or non-ASCII could break the header or produce invalid filenames.
Proposed fix
- const filename = `decision-${decision.title
- .toLowerCase()
- .replace(/\s+/g, "-")}.${format === "markdown" ? "md" : format}`;
+ const safeTitle = decision.title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "")
+ .slice(0, 100);
+ const ext = format === "markdown" ? "md" : format === "json" ? "json" : "txt";
+ const filename = `decision-${safeTitle}.${ext}`;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const filename = `decision-${decision.title | |
| .toLowerCase() | |
| .replace(/\s+/g, "-")}.${format === "markdown" ? "md" : format}`; | |
| const safeTitle = decision.title | |
| .toLowerCase() | |
| .replace(/[^a-z0-9]+/g, "-") | |
| .replace(/^-|-$/g, "") | |
| .slice(0, 100); | |
| const ext = format === "markdown" ? "md" : format === "json" ? "json" : "txt"; | |
| const filename = `decision-${safeTitle}.${ext}`; |
🤖 Prompt for AI Agents
In `@app/api/projects/`[id]/decisions/[decisionId]/export/route.ts around lines 86
- 88, The filename creation uses raw user input (decision.title) and must be
sanitized before putting it in the Content-Disposition header; update the code
that builds filename (the filename const in route.ts) to (1) normalize and trim
the title, (2) remove or replace control characters like newlines and quotes and
any path separators, (3) restrict to a safe subset (e.g., convert to ASCII-like
slug with only letters, numbers, hyphens, and underscores, collapsing
consecutive non-safe chars), and (4) provide a safe fallback (e.g., "decision")
if the result is empty; finally ensure the header uses an encoded filename
fallback (RFC5987 / encodeURIComponent) when setting Content-Disposition so
non-ASCII characters are handled safely.
| decision.status === "handed_off" ? "default" : "secondary" | ||
| } | ||
| > | ||
| {decision.status.replace("_", " ")} |
There was a problem hiding this comment.
replace("_", " ") only replaces the first underscore.
String.prototype.replace with a string pattern replaces only the first occurrence. If statuses like "in_progress_review" exist, this would produce "in progress_review".
Proposed fix
- {decision.status.replace("_", " ")}
+ {decision.status.replaceAll("_", " ")}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {decision.status.replace("_", " ")} | |
| {decision.status.replaceAll("_", " ")} |
🤖 Prompt for AI Agents
In `@app/projects/`[id]/decisions/[decisionId]/page.tsx at line 62, The code uses
decision.status.replace("_", " ") which only replaces the first underscore;
update the rendering logic that references decision.status.replace to replace
all underscores (e.g., use a global replacement or split/join) so
multi-underscore statuses like "in_progress_review" become "in progress review".
Ensure this change is applied where decision.status.replace is used in the
page.tsx render.
| <Button variant="outline" size="sm"> | ||
| <Filter className="w-4 h-4 mr-2" /> | ||
| Filter | ||
| </Button> |
There was a problem hiding this comment.
Filter button is non-functional.
This button renders but has no onClick handler or linked behavior. Consider either wiring it up or removing it to avoid confusing users.
🤖 Prompt for AI Agents
In `@app/projects/`[id]/decisions/page.tsx around lines 54 - 57, The Filter button
(the Button element rendering the Filter icon in page.tsx) is non-functional;
add an onClick handler to either toggle a local filter UI state (e.g., a
useState like showFilters and a handler such as handleToggleFilters) or call the
existing filter-opening function if one exists, and ensure the Button's onClick
wires to that handler (or remove the Button entirely if filtering is not
implemented) so the control is not misleading to users.
| {trigger ? ( | ||
| <div onClick={() => setIsOpen(true)}>{trigger}</div> |
There was a problem hiding this comment.
Custom trigger path doesn't initialize preview and has accessibility issues.
Two problems:
- Empty preview on open: When opened via the custom
trigger,updatePreviewis never called, so the preview pane is blank. Compare with the default button path (line 370–371) which does callupdatePreview. - Accessibility: Wrapping
triggerin a plain<div onClick>provides no keyboard support (Enter/Space), norole="button", and notabIndex. Since the trigger on the list page is an actual<Button>, the outer<div>intercepts the click but doesn't add keyboard semantics.
Proposed fix
- <div onClick={() => setIsOpen(true)}>{trigger}</div>
+ <div
+ role="button"
+ tabIndex={0}
+ onClick={() => {
+ setIsOpen(true);
+ updatePreview(selectedFormat);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ setIsOpen(true);
+ updatePreview(selectedFormat);
+ }
+ }}
+ >
+ {trigger}
+ </div>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {trigger ? ( | |
| <div onClick={() => setIsOpen(true)}>{trigger}</div> | |
| {trigger ? ( | |
| <div | |
| role="button" | |
| tabIndex={0} | |
| onClick={() => { | |
| setIsOpen(true); | |
| updatePreview(selectedFormat); | |
| }} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" || e.key === " ") { | |
| e.preventDefault(); | |
| setIsOpen(true); | |
| updatePreview(selectedFormat); | |
| } | |
| }} | |
| > | |
| {trigger} | |
| </div> |
🤖 Prompt for AI Agents
In `@components/DecisionExportDialog.tsx` around lines 363 - 364, The custom
trigger wrapper currently only does setIsOpen(true) which both fails to call
updatePreview (so the preview is empty) and creates accessibility issues by
using a plain <div>; change the trigger handling to invoke updatePreview()
before opening (call updatePreview() then setIsOpen(true)) and make the trigger
keyboard-accessible by forwarding proper button semantics instead of a bare div
— e.g., use React.cloneElement(trigger, { onClick: () => { updatePreview();
setIsOpen(true); }, onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ')
{ e.preventDefault(); updatePreview(); setIsOpen(true); } }, role: 'button',
tabIndex: 0 }) so the custom trigger receives click/keyboard handlers,
role="button" and tabIndex to match how the default Button path triggers
updatePreview and supports keyboard interaction.
| {isOpen && ( | ||
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Modal lacks standard accessibility patterns.
The custom modal overlay has no Escape key handler, no focus trap, and no aria-* attributes (role="dialog", aria-modal="true", aria-labelledby). Consider using a proper dialog primitive (e.g., Radix Dialog which this project likely already has via shadcn/ui) instead of a hand-rolled modal.
#!/bin/bash
# Check if Radix Dialog is already available in the project
fd "dialog" --type f --extension tsx --search-path components/uiAlso applies to: 391-397
🤖 Prompt for AI Agents
In `@components/DecisionExportDialog.tsx` around lines 380 - 381, The custom modal
in DecisionExportDialog (the isOpen-controlled overlay div) lacks standard
accessibility: replace the hand-rolled overlay and content with the Radix/Dialog
(or the project's Dialog primitive from components/ui) so you get built-in focus
trap, Escape handling, and return-focus behavior; ensure the dialog root is
controlled by the existing isOpen state and calls the component's onClose/close
handler when closed, and add aria attributes (role="dialog" / aria-modal="true"
and aria-labelledby pointing to the dialog title element) for the dialog content
that previously lived inside the fixed inset div.
| <Button | ||
| size="sm" | ||
| onClick={() => handleExport(selectedFormat)} | ||
| > | ||
| <Download className="w-4 h-4 mr-1" /> | ||
| Download | ||
| </Button> |
There was a problem hiding this comment.
The "Download" button doesn't actually download a file.
handleExport only calls the optional onExport callback. Neither the detail page nor the list page passes onExport, so clicking "Download" is a no-op. Actual file-download logic (e.g., creating a Blob and triggering a link click) is missing.
Proposed fix — add download logic
const handleExport = (format: ExportFormat) => {
const content = updatePreview(format);
+ // Trigger file download
+ const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ const ext = format === "markdown" ? "md" : format === "json" ? "json" : "txt";
+ a.href = url;
+ a.download = `decision-${decision.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 100)}.${ext}`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
if (onExport) {
onExport(format, content);
}
};🤖 Prompt for AI Agents
In `@components/DecisionExportDialog.tsx` around lines 456 - 462, The Download
button currently calls handleExport(selectedFormat) which only invokes the
optional onExport callback and does nothing when onExport is not provided;
implement fallback download logic inside handleExport in DecisionExportDialog:
when onExport is undefined, generate the export payload (based on selectedFormat
and existing export data/helpers in the component), create a Blob, create an
object URL, programmatically create and click an anchor (<a>) with download
attribute, and revoke the URL after use so the file actually downloads; keep the
existing onExport call path intact and only run the Blob/link code as the
fallback.
What Was Built
A comprehensive Decision Export System for PM Analyzer that enables product managers to export decisions in multiple formats for different downstream tools.
Components Created
DecisionExportDialog.tsx - A reusable export modal component
Export API Route (
/api/projects/[id]/decisions/[decisionId]/export)Decision Detail Page (
/projects/[id]/decisions/[decisionId])Decisions List Page (
/projects/[id]/decisions)Export Formats
Example: Cursor/Claude Code Export
Why This Matters
Product managers need to hand off decisions to:
This feature bridges the gap between PM Analyzer's decision-making capabilities and the tools teams actually use to build software.
How to Test
Files Changed
components/DecisionExportDialog.tsx- New component (12KB)app/api/projects/[id]/decisions/[decisionId]/export/route.ts- New APIapp/projects/[id]/decisions/[decisionId]/page.tsx- Decision detail viewapp/projects/[id]/decisions/page.tsx- Decisions list viewNightly Build (2026-02-07) 🌙
Summary by CodeRabbit