this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add response export downloads

+579 -1
+3 -1
app/(creator)/forms/[id]/responses/page.tsx
··· 3 3 import { notFound } from "next/navigation"; 4 4 5 5 import { EmptyState } from "@/components/empty-state"; 6 + import { ResponseExportActions } from "@/components/response-export-actions"; 6 7 import { Badge } from "@/components/ui/badge"; 7 8 import { Button } from "@/components/ui/button"; 8 9 import { Card } from "@/components/ui/card"; ··· 38 39 <h1 className="mt-3 font-display text-4xl text-[var(--ink)]">{form.title}</h1> 39 40 <p className="mt-2 text-sm leading-6 text-[var(--muted)]">Review anonymous submissions in form order.</p> 40 41 </div> 41 - <div className="flex gap-3"> 42 + <div className="flex flex-wrap gap-3 lg:justify-end"> 42 43 <Badge>{responses.length} submissions</Badge> 44 + <ResponseExportActions formId={form.id} /> 43 45 <Link href={`/forms/${form.id}/edit`}> 44 46 <Button variant="secondary">Back to builder</Button> 45 47 </Link>
+28
app/api/forms/[formId]/export/route.ts
··· 1 + import { handleRouteError } from "@/lib/api"; 2 + import { getServerAuthSession } from "@/lib/auth"; 3 + import { createOwnedFormResponseExport, parseResponseExportFormat } from "@/lib/response-exports"; 4 + 5 + export async function GET(request: Request, context: { params: Promise<{ formId: string }> }) { 6 + const session = await getServerAuthSession(); 7 + 8 + if (!session?.user?.id) { 9 + return Response.json({ error: "Unauthorized" }, { status: 401 }); 10 + } 11 + 12 + try { 13 + const { formId } = await context.params; 14 + const format = parseResponseExportFormat(new URL(request.url).searchParams.get("format")); 15 + const exportFile = await createOwnedFormResponseExport(session.user.id, formId, format); 16 + 17 + return new Response(exportFile.body, { 18 + status: 200, 19 + headers: { 20 + "Content-Type": exportFile.contentType, 21 + "Content-Disposition": `attachment; filename="${exportFile.filename}"`, 22 + "Cache-Control": "no-store", 23 + }, 24 + }); 25 + } catch (error) { 26 + return handleRouteError(error); 27 + } 28 + }
+19
bun.lock
··· 19 19 "react": "^19.2.4", 20 20 "react-dom": "^19.2.4", 21 21 "tailwind-merge": "^3.5.0", 22 + "xlsx": "^0.18.5", 22 23 "zod": "^4.3.6", 23 24 }, 24 25 "devDependencies": { ··· 333 334 334 335 "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], 335 336 337 + "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], 338 + 336 339 "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], 337 340 338 341 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], ··· 389 392 390 393 "caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], 391 394 395 + "cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="], 396 + 392 397 "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], 393 398 394 399 "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], ··· 401 406 402 407 "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], 403 408 409 + "codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="], 410 + 404 411 "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 405 412 406 413 "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], ··· 414 421 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 415 422 416 423 "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], 424 + 425 + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], 417 426 418 427 "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], 419 428 ··· 539 548 540 549 "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], 541 550 551 + "frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="], 552 + 542 553 "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], 543 554 544 555 "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], ··· 885 896 886 897 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 887 898 899 + "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="], 900 + 888 901 "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], 889 902 890 903 "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], ··· 965 978 966 979 "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], 967 980 981 + "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], 982 + 983 + "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], 984 + 968 985 "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], 986 + 987 + "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="], 969 988 970 989 "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], 971 990
+88
components/response-export-actions.tsx
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + import { Download, FileSpreadsheet, LoaderCircle } from "lucide-react"; 5 + 6 + import { Button } from "@/components/ui/button"; 7 + import { ToastViewport, type ToastData } from "@/components/ui/toast"; 8 + 9 + type ResponseExportFormat = "csv" | "xlsx"; 10 + 11 + function getFilenameFromDisposition(disposition: string | null, fallback: string) { 12 + if (!disposition) { 13 + return fallback; 14 + } 15 + 16 + const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); 17 + 18 + if (utf8Match?.[1]) { 19 + return decodeURIComponent(utf8Match[1]); 20 + } 21 + 22 + const basicMatch = disposition.match(/filename="?([^";]+)"?/i); 23 + return basicMatch?.[1] ?? fallback; 24 + } 25 + 26 + export function ResponseExportActions({ formId }: { formId: string }) { 27 + const [busyFormat, setBusyFormat] = useState<ResponseExportFormat | null>(null); 28 + const [toasts, setToasts] = useState<ToastData[]>([]); 29 + 30 + function showToast(message: string, variant: ToastData["variant"] = "success") { 31 + const id = crypto.randomUUID(); 32 + setToasts((current) => [...current, { id, message, variant }]); 33 + } 34 + 35 + function dismissToast(id: string) { 36 + setToasts((current) => current.filter((toast) => toast.id !== id)); 37 + } 38 + 39 + async function exportResponses(format: ResponseExportFormat) { 40 + try { 41 + setBusyFormat(format); 42 + 43 + const response = await fetch(`/api/forms/${formId}/export?format=${format}`); 44 + 45 + if (!response.ok) { 46 + const payload = (await response.json().catch(() => ({}))) as { error?: string }; 47 + throw new Error(payload.error ?? "Could not export responses."); 48 + } 49 + 50 + const blob = await response.blob(); 51 + const url = URL.createObjectURL(blob); 52 + const filename = getFilenameFromDisposition( 53 + response.headers.get("Content-Disposition"), 54 + `responses.${format}`, 55 + ); 56 + const link = document.createElement("a"); 57 + 58 + link.href = url; 59 + link.download = filename; 60 + document.body.appendChild(link); 61 + link.click(); 62 + link.remove(); 63 + URL.revokeObjectURL(url); 64 + 65 + showToast(`Downloaded ${format.toUpperCase()} export.`); 66 + } catch (error) { 67 + showToast(error instanceof Error ? error.message : "Could not export responses.", "error"); 68 + } finally { 69 + setBusyFormat(null); 70 + } 71 + } 72 + 73 + return ( 74 + <> 75 + <div className="flex flex-wrap gap-3"> 76 + <Button variant="secondary" disabled={busyFormat !== null} onClick={() => exportResponses("csv")}> 77 + {busyFormat === "csv" ? <LoaderCircle className="size-4 animate-spin" /> : <Download className="size-4" />} 78 + Export CSV 79 + </Button> 80 + <Button variant="secondary" disabled={busyFormat !== null} onClick={() => exportResponses("xlsx")}> 81 + {busyFormat === "xlsx" ? <LoaderCircle className="size-4 animate-spin" /> : <FileSpreadsheet className="size-4" />} 82 + Export XLSX 83 + </Button> 84 + </div> 85 + <ToastViewport toasts={toasts} onDismiss={dismissToast} /> 86 + </> 87 + ); 88 + }
+188
lib/response-exports.ts
··· 1 + import { utils, write } from "xlsx"; 2 + 3 + import { isQuestionBlock, serializeBlock, type SerializedBlock } from "@/lib/blocks"; 4 + import { db } from "@/lib/db"; 5 + import { AppError } from "@/lib/errors"; 6 + import { slugify } from "@/lib/utils"; 7 + 8 + export type ResponseExportFormat = "csv" | "xlsx"; 9 + 10 + type OwnedFormExportRecord = Awaited<ReturnType<typeof loadOwnedFormResponsesForExport>>; 11 + 12 + type ExportAnswerValue = string | string[] | undefined; 13 + 14 + export type ResponseExportColumn = { 15 + key: string; 16 + label: string; 17 + blockId?: string; 18 + }; 19 + 20 + export type ResponseExportRow = { 21 + values: Record<string, string>; 22 + }; 23 + 24 + export type ResponseExportDataset = { 25 + columns: ResponseExportColumn[]; 26 + rows: ResponseExportRow[]; 27 + }; 28 + 29 + const METADATA_COLUMNS: ResponseExportColumn[] = [ 30 + { key: "submissionNumber", label: "Submission #" }, 31 + { key: "submittedAt", label: "Submitted at" }, 32 + { key: "responseId", label: "Response ID" }, 33 + ]; 34 + 35 + export async function loadOwnedFormResponsesForExport(userId: string, formId: string) { 36 + const form = await db.form.findFirst({ 37 + where: { 38 + id: formId, 39 + userId, 40 + }, 41 + include: { 42 + blocks: { 43 + orderBy: { 44 + position: "asc", 45 + }, 46 + }, 47 + responses: { 48 + orderBy: [{ submittedAt: "asc" }, { id: "asc" }], 49 + }, 50 + }, 51 + }); 52 + 53 + if (!form) { 54 + throw new AppError("Form not found.", 404); 55 + } 56 + 57 + return form; 58 + } 59 + 60 + function createQuestionColumnLabel(block: SerializedBlock, questionIndex: number, seenLabels: Map<string, number>) { 61 + const baseLabel = block.title.trim() || `Question ${questionIndex}`; 62 + const occurrence = (seenLabels.get(baseLabel) ?? 0) + 1; 63 + seenLabels.set(baseLabel, occurrence); 64 + 65 + return occurrence === 1 ? baseLabel : `${baseLabel} (${occurrence})`; 66 + } 67 + 68 + export function serializeExportAnswer(value: ExportAnswerValue) { 69 + if (Array.isArray(value)) { 70 + return value.join(" | "); 71 + } 72 + 73 + return typeof value === "string" ? value : ""; 74 + } 75 + 76 + export function buildResponseExportDataset(form: OwnedFormExportRecord): ResponseExportDataset { 77 + const questionBlocks = form.blocks.map(serializeBlock).filter((block) => isQuestionBlock(block.type)); 78 + const seenLabels = new Map<string, number>(); 79 + 80 + const columns = [ 81 + ...METADATA_COLUMNS, 82 + ...questionBlocks.map((block, index) => ({ 83 + key: block.id, 84 + label: createQuestionColumnLabel(block, index + 1, seenLabels), 85 + blockId: block.id, 86 + })), 87 + ]; 88 + 89 + const rows = form.responses.map((response, index) => { 90 + const answers = 91 + typeof response.answersJson === "object" && response.answersJson && !Array.isArray(response.answersJson) 92 + ? (response.answersJson as Record<string, ExportAnswerValue>) 93 + : {}; 94 + 95 + const values = columns.reduce<Record<string, string>>((accumulator, column) => { 96 + if (column.key === "submissionNumber") { 97 + accumulator[column.key] = String(index + 1); 98 + return accumulator; 99 + } 100 + 101 + if (column.key === "submittedAt") { 102 + accumulator[column.key] = response.submittedAt.toISOString(); 103 + return accumulator; 104 + } 105 + 106 + if (column.key === "responseId") { 107 + accumulator[column.key] = response.id; 108 + return accumulator; 109 + } 110 + 111 + accumulator[column.key] = serializeExportAnswer(answers[column.key]); 112 + return accumulator; 113 + }, {}); 114 + 115 + return { values }; 116 + }); 117 + 118 + return { 119 + columns, 120 + rows, 121 + }; 122 + } 123 + 124 + function escapeCsvCell(value: string) { 125 + if (!/[",\n\r]/.test(value)) { 126 + return value; 127 + } 128 + 129 + return `"${value.replaceAll('"', '""')}"`; 130 + } 131 + 132 + export function createCsvExportBuffer(dataset: ResponseExportDataset) { 133 + const headerRow = dataset.columns.map((column) => escapeCsvCell(column.label)).join(","); 134 + const dataRows = dataset.rows.map((row) => dataset.columns.map((column) => escapeCsvCell(row.values[column.key] ?? "")).join(",")); 135 + const content = [headerRow, ...dataRows].join("\r\n"); 136 + 137 + return Buffer.from(`\uFEFF${content}`, "utf8"); 138 + } 139 + 140 + export function createXlsxExportBuffer(dataset: ResponseExportDataset) { 141 + const worksheet = utils.aoa_to_sheet([ 142 + dataset.columns.map((column) => column.label), 143 + ...dataset.rows.map((row) => dataset.columns.map((column) => row.values[column.key] ?? "")), 144 + ]); 145 + const workbook = utils.book_new(); 146 + 147 + utils.book_append_sheet(workbook, worksheet, "Responses"); 148 + 149 + return write(workbook, { 150 + type: "buffer", 151 + bookType: "xlsx", 152 + }); 153 + } 154 + 155 + export function getResponseExportFilename(slug: string, format: ResponseExportFormat) { 156 + const safeSlug = slugify(slug) || "form"; 157 + return `${safeSlug}-responses.${format}`; 158 + } 159 + 160 + export function getResponseExportContentType(format: ResponseExportFormat) { 161 + return format === "csv" 162 + ? "text/csv; charset=utf-8" 163 + : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; 164 + } 165 + 166 + export function parseResponseExportFormat(value: string | null): ResponseExportFormat { 167 + if (value === "csv" || value === "xlsx") { 168 + return value; 169 + } 170 + 171 + throw new AppError("Unsupported export format.", 422); 172 + } 173 + 174 + export async function createOwnedFormResponseExport(userId: string, formId: string, format: ResponseExportFormat) { 175 + const form = await loadOwnedFormResponsesForExport(userId, formId); 176 + const dataset = buildResponseExportDataset(form); 177 + const filename = getResponseExportFilename(form.slug, format); 178 + const contentType = getResponseExportContentType(format); 179 + const body = format === "csv" ? createCsvExportBuffer(dataset) : createXlsxExportBuffer(dataset); 180 + 181 + return { 182 + filename, 183 + contentType, 184 + body, 185 + dataset, 186 + form, 187 + }; 188 + }
+2
openspec/changes/archive/2026-04-09-export-form-results/.openspec.yaml
··· 1 + schema: spec-driven 2 + created: 2026-04-09
+85
openspec/changes/archive/2026-04-09-export-form-results/design.md
··· 1 + ## Context 2 + 3 + Lively Forms already stores anonymous responses and gives creators an authenticated response review surface, but that data currently stays inside the product. Exporting responses is a cross-cutting addition because it touches creator UI, ownership checks, response shaping, and file generation for multiple output formats. 4 + 5 + The current codebase already has server-side form ownership checks and response retrieval in `lib/forms.ts` plus creator-facing response pages under `app/(creator)/forms/[id]/responses/`. The new export flow should fit that model: exports are generated on the server for owned forms only and downloaded from the creator surface. 6 + 7 + ## Goals / Non-Goals 8 + 9 + **Goals:** 10 + - Let creators export responses for forms they own. 11 + - Support two practical spreadsheet-oriented formats in the first release: CSV and XLSX. 12 + - Produce stable, readable exports that include submission metadata and one column per answerable block. 13 + - Keep export authorization aligned with existing response review ownership rules. 14 + - Avoid database schema changes. 15 + 16 + **Non-Goals:** 17 + - Building a generic reporting system or analytics dashboard. 18 + - Supporting every possible format in v1 (PDF, JSON, XML, etc.). 19 + - Exporting per-question aggregates or charts. 20 + - Persisting generated export files for later retrieval. 21 + 22 + ## Decisions 23 + 24 + ### 1. Add export actions to the creator responses surface 25 + The responses list page is the most natural place to export because it already represents the form-level response dataset. This keeps export discoverable and avoids duplicating controls elsewhere. 26 + 27 + **Alternatives considered:** 28 + - Add export actions in the form builder only: rejected because exports belong to response data, not structure editing. 29 + - Add export on each single-response page: rejected because the goal is dataset export, not one-off answer download. 30 + 31 + ### 2. Generate exports on the server per request 32 + CSV/XLSX generation should happen in a server route or equivalent server-only handler after ownership verification. This avoids exposing raw response shaping logic to the client and keeps sensitive data authorization centralized. 33 + 34 + **Alternatives considered:** 35 + - Client-side export from already rendered response data: rejected because the full dataset may not be loaded and authorization should remain server-enforced. 36 + - Background job + stored files: rejected as unnecessary complexity for an on-demand export feature. 37 + 38 + ### 3. Scope v1 formats to CSV and XLSX 39 + CSV gives a universal, low-dependency format for imports and scripts. XLSX covers common spreadsheet workflows and preserves a cleaner multi-column experience for non-technical creators. 40 + 41 + **Alternatives considered:** 42 + - CSV only: simpler, but does not satisfy spreadsheet-first users who expect native Excel-compatible downloads. 43 + - Add JSON in v1: useful for integrations, but weaker product fit than spreadsheet exports for the current audience. 44 + - Add PDF in v1: poor fit for tabular response datasets. 45 + 46 + ### 4. Shape exports as one row per submission with derived columns 47 + Each exported row should represent one submission. Columns should begin with submission metadata (submission number, submitted timestamp, response ID) followed by one column per answerable block in saved form order. Column labels should use the block prompt when present, with a deterministic fallback for untitled questions. 48 + 49 + This mirrors how creators think about submissions and makes the data immediately usable in spreadsheets. 50 + 51 + **Alternatives considered:** 52 + - One sheet/tab per question: rejected because it complicates analysis and breaks the simple spreadsheet mental model. 53 + - Raw JSON blobs per submission: rejected because it reduces usability in CSV/XLSX. 54 + 55 + ### 5. Serialize complex answers into readable cells 56 + Short and long text answers export as plain text. Single choice exports as the selected option. Multiple choice exports as a delimited string in a single cell for both CSV and XLSX so both formats share the same conceptual schema. 57 + 58 + **Alternatives considered:** 59 + - Expand multiple choice into one boolean column per option: powerful, but couples exports to mutable option sets and increases column sprawl in v1. 60 + - Store arrays in XLSX but flatten only CSV: rejected because differing schemas by format would be harder to explain and test. 61 + 62 + ### 6. Reuse existing response/domain mapping helpers where possible 63 + The implementation should centralize export row construction in shared server-side helpers so CSV and XLSX use the same data shaping logic. Format writers should consume the same normalized export dataset. 64 + 65 + **Alternatives considered:** 66 + - Separate CSV/XLSX shaping paths: rejected because it invites drift and duplication. 67 + 68 + ## Risks / Trade-offs 69 + 70 + - **Wide forms can produce wide exports** → Use saved block order and straightforward headings; accept width as a natural consequence of flexible forms. 71 + - **Untitled or duplicate question prompts can create confusing columns** → Provide deterministic fallback labels and preserve order so columns remain understandable. 72 + - **CSV formatting can be lossy for multiline text or separators** → Use a proper CSV serializer/escaping strategy rather than hand-built strings. 73 + - **New XLSX dependency increases bundle and maintenance surface** → Keep workbook generation server-only and use a minimal, well-supported library. 74 + - **Large response sets may increase request time** → Start with on-demand synchronous exports and monitor; optimize later if volume warrants it. 75 + 76 + ## Migration Plan 77 + 78 + - No database migration is required. 79 + - Ship the server export handler and creator UI together so the control is only visible when the backend exists. 80 + - If rollback is needed, remove or disable the export action while leaving response review unaffected. 81 + 82 + ## Open Questions 83 + 84 + - Whether the exported timestamp should use UTC ISO strings or a more human-readable format. Initial recommendation: use a stable machine-friendly timestamp value. 85 + - Exact filename convention. Initial recommendation: include form slug and format, e.g. `<slug>-responses.csv` and `<slug>-responses.xlsx`.
+27
openspec/changes/archive/2026-04-09-export-form-results/proposal.md
··· 1 + ## Why 2 + 3 + Creators can review responses inside Lively Forms today, but they cannot take that data into spreadsheets, reporting workflows, or external tools. Adding export support now unlocks a practical next step for real form usage and addresses a common expectation for form products. 4 + 5 + ## What Changes 6 + 7 + - Add creator-facing export actions for form responses from the response review area. 8 + - Support exporting responses in CSV and XLSX formats for owned forms. 9 + - Export one row per submission with stable columns derived from the form structure. 10 + - Include submission metadata such as submission number and submitted timestamp in the export output. 11 + - Serialize multi-select answers into a readable cell value for spreadsheet use. 12 + - Prevent exporting responses for forms the current creator does not own. 13 + 14 + ## Capabilities 15 + 16 + ### New Capabilities 17 + - `response-export`: Allow creators to export owned form responses in spreadsheet-friendly formats. 18 + 19 + ### Modified Capabilities 20 + - `response-review`: Extend response review so creators can trigger exports for owned forms from the responses surface. 21 + 22 + ## Impact 23 + 24 + - Affected areas: response review pages, form/response data shaping, export route or server action, and download UI. 25 + - Likely code: `app/(creator)/forms/[id]/responses/`, `lib/forms.ts`, shared response/domain types, and API/server export logic. 26 + - Likely dependencies: XLSX generation library or equivalent workbook writer. 27 + - Security: ownership checks must apply to every export request.
+48
openspec/changes/archive/2026-04-09-export-form-results/specs/response-export/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Creator can export owned form responses in CSV and XLSX formats 4 + The system SHALL allow an authenticated creator to export responses for a form they own in CSV and XLSX formats. 5 + 6 + #### Scenario: Creator exports owned form responses as CSV 7 + - **WHEN** an authenticated creator requests a CSV export for a form they own 8 + - **THEN** the system downloads a CSV file containing that form's responses 9 + 10 + #### Scenario: Creator exports owned form responses as XLSX 11 + - **WHEN** an authenticated creator requests an XLSX export for a form they own 12 + - **THEN** the system downloads an XLSX file containing that form's responses 13 + 14 + ### Requirement: Export requests are restricted by form ownership 15 + The system SHALL allow response exports only for forms owned by the authenticated creator and SHALL deny export access for forms owned by others. 16 + 17 + #### Scenario: Creator exports another creator's form 18 + - **WHEN** an authenticated creator requests an export for a form they do not own 19 + - **THEN** the system denies access and does not return response data 20 + 21 + ### Requirement: Export output uses one row per submission with stable columns 22 + The system SHALL export one row per submission and SHALL include submission metadata plus one column for each answerable block in the form's saved order. 23 + 24 + #### Scenario: Form contains multiple question types 25 + - **WHEN** a creator exports responses for a form with multiple answerable block types 26 + - **THEN** the export contains submission metadata columns followed by one column per answerable block in saved order 27 + 28 + #### Scenario: Form contains text-only blocks 29 + - **WHEN** a creator exports responses for a form that includes non-answerable text blocks 30 + - **THEN** the export excludes those text blocks from answer columns 31 + 32 + ### Requirement: Exported answers are spreadsheet-readable 33 + The system SHALL serialize answers into readable cell values suitable for spreadsheet use across supported formats. 34 + 35 + #### Scenario: Submission contains a multiple choice answer 36 + - **WHEN** a creator exports responses that include a multiple choice answer 37 + - **THEN** the selected options are written as a readable delimited value in the exported cell 38 + 39 + #### Scenario: Submission contains long text content 40 + - **WHEN** a creator exports responses that include long text answers 41 + - **THEN** the exported file preserves the answer content as text in the corresponding cell 42 + 43 + ### Requirement: Exports remain available when a form has no submissions 44 + The system SHALL allow a creator to export an owned form even when it has no submissions and SHALL generate a file with headers only. 45 + 46 + #### Scenario: Creator exports an owned form with no responses 47 + - **WHEN** an authenticated creator exports a form they own that has no submissions 48 + - **THEN** the system downloads a valid export file containing column headers and no submission rows
+8
openspec/changes/archive/2026-04-09-export-form-results/specs/response-review/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Creator can start response exports from the responses view 4 + The system SHALL present response export actions in the responses view for a form owned by the authenticated creator. 5 + 6 + #### Scenario: Creator opens responses for an owned form 7 + - **WHEN** an authenticated creator opens the responses view for a form they own 8 + - **THEN** the system displays available export actions for the supported response export formats
+27
openspec/changes/archive/2026-04-09-export-form-results/tasks.md
··· 1 + ## 1. Export data shaping 2 + 3 + - [x] 1.1 Add shared server-side helpers that load an owned form with its responses for export. 4 + - [x] 1.2 Build a normalized export row shape with submission metadata and one column per answerable block in saved order. 5 + - [x] 1.3 Implement answer serialization rules for short text, long text, single choice, and multiple choice responses. 6 + - [x] 1.4 Add deterministic fallback column labels for untitled or duplicate questions. 7 + 8 + ## 2. File generation 9 + 10 + - [x] 2.1 Add or configure the XLSX dependency needed for workbook generation. 11 + - [x] 2.2 Implement CSV file generation using the normalized export dataset and proper escaping. 12 + - [x] 2.3 Implement XLSX file generation using the same normalized export dataset. 13 + - [x] 2.4 Add filename generation for exported files based on the form slug and selected format. 14 + 15 + ## 3. Creator export flow 16 + 17 + - [x] 3.1 Add a server export endpoint or server action that validates form ownership before generating a download. 18 + - [x] 3.2 Wire CSV and XLSX export actions into the creator responses view. 19 + - [x] 3.3 Handle empty-response exports so creators can still download header-only files. 20 + - [x] 3.4 Show loading and error states that fit the existing creator UI patterns. 21 + 22 + ## 4. Verification 23 + 24 + - [x] 4.1 Verify owned-form exports succeed in both CSV and XLSX formats. 25 + - [x] 4.2 Verify export requests for non-owned forms are denied. 26 + - [x] 4.3 Verify exported column order and values match the saved form order and response content. 27 + - [x] 4.4 Run the production build and fix any type or route issues introduced by the change.
+48
openspec/specs/response-export/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Creator can export owned form responses in CSV and XLSX formats 4 + The system SHALL allow an authenticated creator to export responses for a form they own in CSV and XLSX formats. 5 + 6 + #### Scenario: Creator exports owned form responses as CSV 7 + - **WHEN** an authenticated creator requests a CSV export for a form they own 8 + - **THEN** the system downloads a CSV file containing that form's responses 9 + 10 + #### Scenario: Creator exports owned form responses as XLSX 11 + - **WHEN** an authenticated creator requests an XLSX export for a form they own 12 + - **THEN** the system downloads an XLSX file containing that form's responses 13 + 14 + ### Requirement: Export requests are restricted by form ownership 15 + The system SHALL allow response exports only for forms owned by the authenticated creator and SHALL deny export access for forms owned by others. 16 + 17 + #### Scenario: Creator exports another creator's form 18 + - **WHEN** an authenticated creator requests an export for a form they do not own 19 + - **THEN** the system denies access and does not return response data 20 + 21 + ### Requirement: Export output uses one row per submission with stable columns 22 + The system SHALL export one row per submission and SHALL include submission metadata plus one column for each answerable block in the form's saved order. 23 + 24 + #### Scenario: Form contains multiple question types 25 + - **WHEN** a creator exports responses for a form with multiple answerable block types 26 + - **THEN** the export contains submission metadata columns followed by one column per answerable block in saved order 27 + 28 + #### Scenario: Form contains text-only blocks 29 + - **WHEN** a creator exports responses for a form that includes non-answerable text blocks 30 + - **THEN** the export excludes those text blocks from answer columns 31 + 32 + ### Requirement: Exported answers are spreadsheet-readable 33 + The system SHALL serialize answers into readable cell values suitable for spreadsheet use across supported formats. 34 + 35 + #### Scenario: Submission contains a multiple choice answer 36 + - **WHEN** a creator exports responses that include a multiple choice answer 37 + - **THEN** the selected options are written as a readable delimited value in the exported cell 38 + 39 + #### Scenario: Submission contains long text content 40 + - **WHEN** a creator exports responses that include long text answers 41 + - **THEN** the exported file preserves the answer content as text in the corresponding cell 42 + 43 + ### Requirement: Exports remain available when a form has no submissions 44 + The system SHALL allow a creator to export an owned form even when it has no submissions and SHALL generate a file with headers only. 45 + 46 + #### Scenario: Creator exports an owned form with no responses 47 + - **WHEN** an authenticated creator exports a form they own that has no submissions 48 + - **THEN** the system downloads a valid export file containing column headers and no submission rows
+7
openspec/specs/response-review/spec.md
··· 21 21 #### Scenario: Creator inspects response with text blocks in the form 22 22 - **WHEN** an authenticated creator views a response for a form that includes text blocks 23 23 - **THEN** the system shows only answerable block responses while preserving the form order context for the submission 24 + 25 + ### Requirement: Creator can start response exports from the responses view 26 + The system SHALL present response export actions in the responses view for a form owned by the authenticated creator. 27 + 28 + #### Scenario: Creator opens responses for an owned form 29 + - **WHEN** an authenticated creator opens the responses view for a form they own 30 + - **THEN** the system displays available export actions for the supported response export formats
+1
package.json
··· 42 42 "react": "^19.2.4", 43 43 "react-dom": "^19.2.4", 44 44 "tailwind-merge": "^3.5.0", 45 + "xlsx": "^0.18.5", 45 46 "zod": "^4.3.6" 46 47 } 47 48 }