From e69a4f6687a5e40d8e96ff5265dfec6af8d3daa6 Mon Sep 17 00:00:00 2001 From: Michael Chernigin Date: Thu, 9 Apr 2026 12:19:51 +0400 Subject: [PATCH] feat: add response export downloads --- app/(creator)/forms/[id]/responses/page.tsx | 4 +- app/api/forms/[formId]/export/route.ts | 28 +++ bun.lock | 19 ++ components/response-export-actions.tsx | 88 ++++++++ lib/response-exports.ts | 188 ++++++++++++++++++ .../.openspec.yaml | 2 + .../2026-04-09-export-form-results/design.md | 85 ++++++++ .../proposal.md | 27 +++ .../specs/response-export/spec.md | 48 +++++ .../specs/response-review/spec.md | 8 + .../2026-04-09-export-form-results/tasks.md | 27 +++ openspec/specs/response-export/spec.md | 48 +++++ openspec/specs/response-review/spec.md | 7 + package.json | 1 + 14 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 app/api/forms/[formId]/export/route.ts create mode 100644 components/response-export-actions.tsx create mode 100644 lib/response-exports.ts create mode 100644 openspec/changes/archive/2026-04-09-export-form-results/.openspec.yaml create mode 100644 openspec/changes/archive/2026-04-09-export-form-results/design.md create mode 100644 openspec/changes/archive/2026-04-09-export-form-results/proposal.md create mode 100644 openspec/changes/archive/2026-04-09-export-form-results/specs/response-export/spec.md create mode 100644 openspec/changes/archive/2026-04-09-export-form-results/specs/response-review/spec.md create mode 100644 openspec/changes/archive/2026-04-09-export-form-results/tasks.md create mode 100644 openspec/specs/response-export/spec.md diff --git a/app/(creator)/forms/[id]/responses/page.tsx b/app/(creator)/forms/[id]/responses/page.tsx index 30efdd4..e389cc8 100644 --- a/app/(creator)/forms/[id]/responses/page.tsx +++ b/app/(creator)/forms/[id]/responses/page.tsx @@ -3,6 +3,7 @@ import { ArrowRight } from "lucide-react"; import { notFound } from "next/navigation"; import { EmptyState } from "@/components/empty-state"; +import { ResponseExportActions } from "@/components/response-export-actions"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -38,8 +39,9 @@ export default async function ResponsesPage({ params }: { params: Promise<{ id:

{form.title}

Review anonymous submissions in form order.

-
+
{responses.length} submissions + diff --git a/app/api/forms/[formId]/export/route.ts b/app/api/forms/[formId]/export/route.ts new file mode 100644 index 0000000..66625be --- /dev/null +++ b/app/api/forms/[formId]/export/route.ts @@ -0,0 +1,28 @@ +import { handleRouteError } from "@/lib/api"; +import { getServerAuthSession } from "@/lib/auth"; +import { createOwnedFormResponseExport, parseResponseExportFormat } from "@/lib/response-exports"; + +export async function GET(request: Request, context: { params: Promise<{ formId: string }> }) { + const session = await getServerAuthSession(); + + if (!session?.user?.id) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { formId } = await context.params; + const format = parseResponseExportFormat(new URL(request.url).searchParams.get("format")); + const exportFile = await createOwnedFormResponseExport(session.user.id, formId, format); + + return new Response(exportFile.body, { + status: 200, + headers: { + "Content-Type": exportFile.contentType, + "Content-Disposition": `attachment; filename="${exportFile.filename}"`, + "Cache-Control": "no-store", + }, + }); + } catch (error) { + return handleRouteError(error); + } +} diff --git a/bun.lock b/bun.lock index 2b88575..3291702 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", + "xlsx": "^0.18.5", "zod": "^4.3.6", }, "devDependencies": { @@ -333,6 +334,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], + "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=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -389,6 +392,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], + "cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -401,6 +406,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -415,6 +422,8 @@ "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -539,6 +548,8 @@ "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="], + "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=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -885,6 +896,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="], + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "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,8 +978,14 @@ "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=="], + "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], + + "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "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=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], diff --git a/components/response-export-actions.tsx b/components/response-export-actions.tsx new file mode 100644 index 0000000..b8514a2 --- /dev/null +++ b/components/response-export-actions.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState } from "react"; +import { Download, FileSpreadsheet, LoaderCircle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { ToastViewport, type ToastData } from "@/components/ui/toast"; + +type ResponseExportFormat = "csv" | "xlsx"; + +function getFilenameFromDisposition(disposition: string | null, fallback: string) { + if (!disposition) { + return fallback; + } + + const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); + + if (utf8Match?.[1]) { + return decodeURIComponent(utf8Match[1]); + } + + const basicMatch = disposition.match(/filename="?([^";]+)"?/i); + return basicMatch?.[1] ?? fallback; +} + +export function ResponseExportActions({ formId }: { formId: string }) { + const [busyFormat, setBusyFormat] = useState(null); + const [toasts, setToasts] = useState([]); + + function showToast(message: string, variant: ToastData["variant"] = "success") { + const id = crypto.randomUUID(); + setToasts((current) => [...current, { id, message, variant }]); + } + + function dismissToast(id: string) { + setToasts((current) => current.filter((toast) => toast.id !== id)); + } + + async function exportResponses(format: ResponseExportFormat) { + try { + setBusyFormat(format); + + const response = await fetch(`/api/forms/${formId}/export?format=${format}`); + + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { error?: string }; + throw new Error(payload.error ?? "Could not export responses."); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const filename = getFilenameFromDisposition( + response.headers.get("Content-Disposition"), + `responses.${format}`, + ); + const link = document.createElement("a"); + + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + + showToast(`Downloaded ${format.toUpperCase()} export.`); + } catch (error) { + showToast(error instanceof Error ? error.message : "Could not export responses.", "error"); + } finally { + setBusyFormat(null); + } + } + + return ( + <> +
+ + +
+ + + ); +} diff --git a/lib/response-exports.ts b/lib/response-exports.ts new file mode 100644 index 0000000..dd0b02a --- /dev/null +++ b/lib/response-exports.ts @@ -0,0 +1,188 @@ +import { utils, write } from "xlsx"; + +import { isQuestionBlock, serializeBlock, type SerializedBlock } from "@/lib/blocks"; +import { db } from "@/lib/db"; +import { AppError } from "@/lib/errors"; +import { slugify } from "@/lib/utils"; + +export type ResponseExportFormat = "csv" | "xlsx"; + +type OwnedFormExportRecord = Awaited>; + +type ExportAnswerValue = string | string[] | undefined; + +export type ResponseExportColumn = { + key: string; + label: string; + blockId?: string; +}; + +export type ResponseExportRow = { + values: Record; +}; + +export type ResponseExportDataset = { + columns: ResponseExportColumn[]; + rows: ResponseExportRow[]; +}; + +const METADATA_COLUMNS: ResponseExportColumn[] = [ + { key: "submissionNumber", label: "Submission #" }, + { key: "submittedAt", label: "Submitted at" }, + { key: "responseId", label: "Response ID" }, +]; + +export async function loadOwnedFormResponsesForExport(userId: string, formId: string) { + const form = await db.form.findFirst({ + where: { + id: formId, + userId, + }, + include: { + blocks: { + orderBy: { + position: "asc", + }, + }, + responses: { + orderBy: [{ submittedAt: "asc" }, { id: "asc" }], + }, + }, + }); + + if (!form) { + throw new AppError("Form not found.", 404); + } + + return form; +} + +function createQuestionColumnLabel(block: SerializedBlock, questionIndex: number, seenLabels: Map) { + const baseLabel = block.title.trim() || `Question ${questionIndex}`; + const occurrence = (seenLabels.get(baseLabel) ?? 0) + 1; + seenLabels.set(baseLabel, occurrence); + + return occurrence === 1 ? baseLabel : `${baseLabel} (${occurrence})`; +} + +export function serializeExportAnswer(value: ExportAnswerValue) { + if (Array.isArray(value)) { + return value.join(" | "); + } + + return typeof value === "string" ? value : ""; +} + +export function buildResponseExportDataset(form: OwnedFormExportRecord): ResponseExportDataset { + const questionBlocks = form.blocks.map(serializeBlock).filter((block) => isQuestionBlock(block.type)); + const seenLabels = new Map(); + + const columns = [ + ...METADATA_COLUMNS, + ...questionBlocks.map((block, index) => ({ + key: block.id, + label: createQuestionColumnLabel(block, index + 1, seenLabels), + blockId: block.id, + })), + ]; + + const rows = form.responses.map((response, index) => { + const answers = + typeof response.answersJson === "object" && response.answersJson && !Array.isArray(response.answersJson) + ? (response.answersJson as Record) + : {}; + + const values = columns.reduce>((accumulator, column) => { + if (column.key === "submissionNumber") { + accumulator[column.key] = String(index + 1); + return accumulator; + } + + if (column.key === "submittedAt") { + accumulator[column.key] = response.submittedAt.toISOString(); + return accumulator; + } + + if (column.key === "responseId") { + accumulator[column.key] = response.id; + return accumulator; + } + + accumulator[column.key] = serializeExportAnswer(answers[column.key]); + return accumulator; + }, {}); + + return { values }; + }); + + return { + columns, + rows, + }; +} + +function escapeCsvCell(value: string) { + if (!/[",\n\r]/.test(value)) { + return value; + } + + return `"${value.replaceAll('"', '""')}"`; +} + +export function createCsvExportBuffer(dataset: ResponseExportDataset) { + const headerRow = dataset.columns.map((column) => escapeCsvCell(column.label)).join(","); + const dataRows = dataset.rows.map((row) => dataset.columns.map((column) => escapeCsvCell(row.values[column.key] ?? "")).join(",")); + const content = [headerRow, ...dataRows].join("\r\n"); + + return Buffer.from(`\uFEFF${content}`, "utf8"); +} + +export function createXlsxExportBuffer(dataset: ResponseExportDataset) { + const worksheet = utils.aoa_to_sheet([ + dataset.columns.map((column) => column.label), + ...dataset.rows.map((row) => dataset.columns.map((column) => row.values[column.key] ?? "")), + ]); + const workbook = utils.book_new(); + + utils.book_append_sheet(workbook, worksheet, "Responses"); + + return write(workbook, { + type: "buffer", + bookType: "xlsx", + }); +} + +export function getResponseExportFilename(slug: string, format: ResponseExportFormat) { + const safeSlug = slugify(slug) || "form"; + return `${safeSlug}-responses.${format}`; +} + +export function getResponseExportContentType(format: ResponseExportFormat) { + return format === "csv" + ? "text/csv; charset=utf-8" + : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; +} + +export function parseResponseExportFormat(value: string | null): ResponseExportFormat { + if (value === "csv" || value === "xlsx") { + return value; + } + + throw new AppError("Unsupported export format.", 422); +} + +export async function createOwnedFormResponseExport(userId: string, formId: string, format: ResponseExportFormat) { + const form = await loadOwnedFormResponsesForExport(userId, formId); + const dataset = buildResponseExportDataset(form); + const filename = getResponseExportFilename(form.slug, format); + const contentType = getResponseExportContentType(format); + const body = format === "csv" ? createCsvExportBuffer(dataset) : createXlsxExportBuffer(dataset); + + return { + filename, + contentType, + body, + dataset, + form, + }; +} diff --git a/openspec/changes/archive/2026-04-09-export-form-results/.openspec.yaml b/openspec/changes/archive/2026-04-09-export-form-results/.openspec.yaml new file mode 100644 index 0000000..98d7681 --- /dev/null +++ b/openspec/changes/archive/2026-04-09-export-form-results/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-09 diff --git a/openspec/changes/archive/2026-04-09-export-form-results/design.md b/openspec/changes/archive/2026-04-09-export-form-results/design.md new file mode 100644 index 0000000..46627ba --- /dev/null +++ b/openspec/changes/archive/2026-04-09-export-form-results/design.md @@ -0,0 +1,85 @@ +## Context + +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. + +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. + +## Goals / Non-Goals + +**Goals:** +- Let creators export responses for forms they own. +- Support two practical spreadsheet-oriented formats in the first release: CSV and XLSX. +- Produce stable, readable exports that include submission metadata and one column per answerable block. +- Keep export authorization aligned with existing response review ownership rules. +- Avoid database schema changes. + +**Non-Goals:** +- Building a generic reporting system or analytics dashboard. +- Supporting every possible format in v1 (PDF, JSON, XML, etc.). +- Exporting per-question aggregates or charts. +- Persisting generated export files for later retrieval. + +## Decisions + +### 1. Add export actions to the creator responses surface +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. + +**Alternatives considered:** +- Add export actions in the form builder only: rejected because exports belong to response data, not structure editing. +- Add export on each single-response page: rejected because the goal is dataset export, not one-off answer download. + +### 2. Generate exports on the server per request +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. + +**Alternatives considered:** +- Client-side export from already rendered response data: rejected because the full dataset may not be loaded and authorization should remain server-enforced. +- Background job + stored files: rejected as unnecessary complexity for an on-demand export feature. + +### 3. Scope v1 formats to CSV and XLSX +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. + +**Alternatives considered:** +- CSV only: simpler, but does not satisfy spreadsheet-first users who expect native Excel-compatible downloads. +- Add JSON in v1: useful for integrations, but weaker product fit than spreadsheet exports for the current audience. +- Add PDF in v1: poor fit for tabular response datasets. + +### 4. Shape exports as one row per submission with derived columns +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. + +This mirrors how creators think about submissions and makes the data immediately usable in spreadsheets. + +**Alternatives considered:** +- One sheet/tab per question: rejected because it complicates analysis and breaks the simple spreadsheet mental model. +- Raw JSON blobs per submission: rejected because it reduces usability in CSV/XLSX. + +### 5. Serialize complex answers into readable cells +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. + +**Alternatives considered:** +- Expand multiple choice into one boolean column per option: powerful, but couples exports to mutable option sets and increases column sprawl in v1. +- Store arrays in XLSX but flatten only CSV: rejected because differing schemas by format would be harder to explain and test. + +### 6. Reuse existing response/domain mapping helpers where possible +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. + +**Alternatives considered:** +- Separate CSV/XLSX shaping paths: rejected because it invites drift and duplication. + +## Risks / Trade-offs + +- **Wide forms can produce wide exports** → Use saved block order and straightforward headings; accept width as a natural consequence of flexible forms. +- **Untitled or duplicate question prompts can create confusing columns** → Provide deterministic fallback labels and preserve order so columns remain understandable. +- **CSV formatting can be lossy for multiline text or separators** → Use a proper CSV serializer/escaping strategy rather than hand-built strings. +- **New XLSX dependency increases bundle and maintenance surface** → Keep workbook generation server-only and use a minimal, well-supported library. +- **Large response sets may increase request time** → Start with on-demand synchronous exports and monitor; optimize later if volume warrants it. + +## Migration Plan + +- No database migration is required. +- Ship the server export handler and creator UI together so the control is only visible when the backend exists. +- If rollback is needed, remove or disable the export action while leaving response review unaffected. + +## Open Questions + +- Whether the exported timestamp should use UTC ISO strings or a more human-readable format. Initial recommendation: use a stable machine-friendly timestamp value. +- Exact filename convention. Initial recommendation: include form slug and format, e.g. `-responses.csv` and `-responses.xlsx`. diff --git a/openspec/changes/archive/2026-04-09-export-form-results/proposal.md b/openspec/changes/archive/2026-04-09-export-form-results/proposal.md new file mode 100644 index 0000000..848b8e0 --- /dev/null +++ b/openspec/changes/archive/2026-04-09-export-form-results/proposal.md @@ -0,0 +1,27 @@ +## Why + +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. + +## What Changes + +- Add creator-facing export actions for form responses from the response review area. +- Support exporting responses in CSV and XLSX formats for owned forms. +- Export one row per submission with stable columns derived from the form structure. +- Include submission metadata such as submission number and submitted timestamp in the export output. +- Serialize multi-select answers into a readable cell value for spreadsheet use. +- Prevent exporting responses for forms the current creator does not own. + +## Capabilities + +### New Capabilities +- `response-export`: Allow creators to export owned form responses in spreadsheet-friendly formats. + +### Modified Capabilities +- `response-review`: Extend response review so creators can trigger exports for owned forms from the responses surface. + +## Impact + +- Affected areas: response review pages, form/response data shaping, export route or server action, and download UI. +- Likely code: `app/(creator)/forms/[id]/responses/`, `lib/forms.ts`, shared response/domain types, and API/server export logic. +- Likely dependencies: XLSX generation library or equivalent workbook writer. +- Security: ownership checks must apply to every export request. diff --git a/openspec/changes/archive/2026-04-09-export-form-results/specs/response-export/spec.md b/openspec/changes/archive/2026-04-09-export-form-results/specs/response-export/spec.md new file mode 100644 index 0000000..7a512cd --- /dev/null +++ b/openspec/changes/archive/2026-04-09-export-form-results/specs/response-export/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: Creator can export owned form responses in CSV and XLSX formats +The system SHALL allow an authenticated creator to export responses for a form they own in CSV and XLSX formats. + +#### Scenario: Creator exports owned form responses as CSV +- **WHEN** an authenticated creator requests a CSV export for a form they own +- **THEN** the system downloads a CSV file containing that form's responses + +#### Scenario: Creator exports owned form responses as XLSX +- **WHEN** an authenticated creator requests an XLSX export for a form they own +- **THEN** the system downloads an XLSX file containing that form's responses + +### Requirement: Export requests are restricted by form ownership +The system SHALL allow response exports only for forms owned by the authenticated creator and SHALL deny export access for forms owned by others. + +#### Scenario: Creator exports another creator's form +- **WHEN** an authenticated creator requests an export for a form they do not own +- **THEN** the system denies access and does not return response data + +### Requirement: Export output uses one row per submission with stable columns +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. + +#### Scenario: Form contains multiple question types +- **WHEN** a creator exports responses for a form with multiple answerable block types +- **THEN** the export contains submission metadata columns followed by one column per answerable block in saved order + +#### Scenario: Form contains text-only blocks +- **WHEN** a creator exports responses for a form that includes non-answerable text blocks +- **THEN** the export excludes those text blocks from answer columns + +### Requirement: Exported answers are spreadsheet-readable +The system SHALL serialize answers into readable cell values suitable for spreadsheet use across supported formats. + +#### Scenario: Submission contains a multiple choice answer +- **WHEN** a creator exports responses that include a multiple choice answer +- **THEN** the selected options are written as a readable delimited value in the exported cell + +#### Scenario: Submission contains long text content +- **WHEN** a creator exports responses that include long text answers +- **THEN** the exported file preserves the answer content as text in the corresponding cell + +### Requirement: Exports remain available when a form has no submissions +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. + +#### Scenario: Creator exports an owned form with no responses +- **WHEN** an authenticated creator exports a form they own that has no submissions +- **THEN** the system downloads a valid export file containing column headers and no submission rows diff --git a/openspec/changes/archive/2026-04-09-export-form-results/specs/response-review/spec.md b/openspec/changes/archive/2026-04-09-export-form-results/specs/response-review/spec.md new file mode 100644 index 0000000..ee6dca6 --- /dev/null +++ b/openspec/changes/archive/2026-04-09-export-form-results/specs/response-review/spec.md @@ -0,0 +1,8 @@ +## ADDED Requirements + +### Requirement: Creator can start response exports from the responses view +The system SHALL present response export actions in the responses view for a form owned by the authenticated creator. + +#### Scenario: Creator opens responses for an owned form +- **WHEN** an authenticated creator opens the responses view for a form they own +- **THEN** the system displays available export actions for the supported response export formats diff --git a/openspec/changes/archive/2026-04-09-export-form-results/tasks.md b/openspec/changes/archive/2026-04-09-export-form-results/tasks.md new file mode 100644 index 0000000..f52d41a --- /dev/null +++ b/openspec/changes/archive/2026-04-09-export-form-results/tasks.md @@ -0,0 +1,27 @@ +## 1. Export data shaping + +- [x] 1.1 Add shared server-side helpers that load an owned form with its responses for export. +- [x] 1.2 Build a normalized export row shape with submission metadata and one column per answerable block in saved order. +- [x] 1.3 Implement answer serialization rules for short text, long text, single choice, and multiple choice responses. +- [x] 1.4 Add deterministic fallback column labels for untitled or duplicate questions. + +## 2. File generation + +- [x] 2.1 Add or configure the XLSX dependency needed for workbook generation. +- [x] 2.2 Implement CSV file generation using the normalized export dataset and proper escaping. +- [x] 2.3 Implement XLSX file generation using the same normalized export dataset. +- [x] 2.4 Add filename generation for exported files based on the form slug and selected format. + +## 3. Creator export flow + +- [x] 3.1 Add a server export endpoint or server action that validates form ownership before generating a download. +- [x] 3.2 Wire CSV and XLSX export actions into the creator responses view. +- [x] 3.3 Handle empty-response exports so creators can still download header-only files. +- [x] 3.4 Show loading and error states that fit the existing creator UI patterns. + +## 4. Verification + +- [x] 4.1 Verify owned-form exports succeed in both CSV and XLSX formats. +- [x] 4.2 Verify export requests for non-owned forms are denied. +- [x] 4.3 Verify exported column order and values match the saved form order and response content. +- [x] 4.4 Run the production build and fix any type or route issues introduced by the change. diff --git a/openspec/specs/response-export/spec.md b/openspec/specs/response-export/spec.md new file mode 100644 index 0000000..7a512cd --- /dev/null +++ b/openspec/specs/response-export/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: Creator can export owned form responses in CSV and XLSX formats +The system SHALL allow an authenticated creator to export responses for a form they own in CSV and XLSX formats. + +#### Scenario: Creator exports owned form responses as CSV +- **WHEN** an authenticated creator requests a CSV export for a form they own +- **THEN** the system downloads a CSV file containing that form's responses + +#### Scenario: Creator exports owned form responses as XLSX +- **WHEN** an authenticated creator requests an XLSX export for a form they own +- **THEN** the system downloads an XLSX file containing that form's responses + +### Requirement: Export requests are restricted by form ownership +The system SHALL allow response exports only for forms owned by the authenticated creator and SHALL deny export access for forms owned by others. + +#### Scenario: Creator exports another creator's form +- **WHEN** an authenticated creator requests an export for a form they do not own +- **THEN** the system denies access and does not return response data + +### Requirement: Export output uses one row per submission with stable columns +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. + +#### Scenario: Form contains multiple question types +- **WHEN** a creator exports responses for a form with multiple answerable block types +- **THEN** the export contains submission metadata columns followed by one column per answerable block in saved order + +#### Scenario: Form contains text-only blocks +- **WHEN** a creator exports responses for a form that includes non-answerable text blocks +- **THEN** the export excludes those text blocks from answer columns + +### Requirement: Exported answers are spreadsheet-readable +The system SHALL serialize answers into readable cell values suitable for spreadsheet use across supported formats. + +#### Scenario: Submission contains a multiple choice answer +- **WHEN** a creator exports responses that include a multiple choice answer +- **THEN** the selected options are written as a readable delimited value in the exported cell + +#### Scenario: Submission contains long text content +- **WHEN** a creator exports responses that include long text answers +- **THEN** the exported file preserves the answer content as text in the corresponding cell + +### Requirement: Exports remain available when a form has no submissions +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. + +#### Scenario: Creator exports an owned form with no responses +- **WHEN** an authenticated creator exports a form they own that has no submissions +- **THEN** the system downloads a valid export file containing column headers and no submission rows diff --git a/openspec/specs/response-review/spec.md b/openspec/specs/response-review/spec.md index 46820ee..166c3ea 100644 --- a/openspec/specs/response-review/spec.md +++ b/openspec/specs/response-review/spec.md @@ -21,3 +21,10 @@ The system SHALL allow an authenticated creator to open an individual response a #### Scenario: Creator inspects response with text blocks in the form - **WHEN** an authenticated creator views a response for a form that includes text blocks - **THEN** the system shows only answerable block responses while preserving the form order context for the submission + +### Requirement: Creator can start response exports from the responses view +The system SHALL present response export actions in the responses view for a form owned by the authenticated creator. + +#### Scenario: Creator opens responses for an owned form +- **WHEN** an authenticated creator opens the responses view for a form they own +- **THEN** the system displays available export actions for the supported response export formats diff --git a/package.json b/package.json index a8f2f6d..c06868c 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", + "xlsx": "^0.18.5", "zod": "^4.3.6" } } -- 2.51.2