From 7a57f014b8dff5af58304ad33dffa753cd8d89d6 Mon Sep 17 00:00:00 2001 From: JohannaPeanut <76495099+JohannaPeanut@users.noreply.github.com> Date: Mon, 8 Apr 2024 16:16:43 +0200 Subject: [PATCH] SurveyResponse: add download for survey results CSV --- .../surveys/[surveyId]/index.tsx | 88 ++++++------------- src/pages/api/survey/[surveyId]/_shared.ts | 66 ++++++++++++++ src/pages/api/survey/[surveyId]/questions.ts | 43 +++++++++ src/pages/api/survey/[surveyId]/responses.ts | 45 ++++++++++ src/pages/api/survey/[surveyId]/results.ts | 86 ++++++++++++++++++ .../utils/format-survey-questions.ts | 55 ++++++++++++ 6 files changed, 320 insertions(+), 63 deletions(-) create mode 100644 src/pages/api/survey/[surveyId]/_shared.ts create mode 100644 src/pages/api/survey/[surveyId]/questions.ts create mode 100644 src/pages/api/survey/[surveyId]/responses.ts create mode 100644 src/pages/api/survey/[surveyId]/results.ts create mode 100644 src/survey-responses/utils/format-survey-questions.ts diff --git a/src/pages/[projectSlug]/surveys/[surveyId]/index.tsx b/src/pages/[projectSlug]/surveys/[surveyId]/index.tsx index e3af01ce1..2554d9db4 100644 --- a/src/pages/[projectSlug]/surveys/[surveyId]/index.tsx +++ b/src/pages/[projectSlug]/surveys/[surveyId]/index.tsx @@ -10,7 +10,7 @@ import { PageHeader } from "src/core/components/pages/PageHeader" import { H2 } from "src/core/components/text" import { useSlugs } from "src/core/hooks" import { LayoutRs, MetaTags } from "src/core/layouts" -import { TQuestion, TSurvey } from "src/survey-public/components/types" +import { TSurvey } from "src/survey-public/components/types" import { getFeedbackDefinitionBySurveySlug, getResponseConfigBySurveySlug, @@ -19,6 +19,10 @@ import { import { GroupedSurveyResponseItem } from "src/survey-responses/components/analysis/GroupedSurveyResponseItem" import getGroupedSurveyResponses from "src/survey-responses/queries/getGroupedSurveyResponses" import { getFormatDistanceInDays } from "src/survey-responses/utils/getFormatDistanceInDays" +import { + extractAndTransformQuestionsFromPages, + transformDeletedQuestions, +} from "src/survey-responses/utils/format-survey-questions" import { SurveyTabs } from "src/surveys/components/SurveyTabs" import getSurvey from "src/surveys/queries/getSurvey" @@ -29,57 +33,6 @@ export const Survey = () => { const [{ groupedSurveyResponsesFirstPart, surveySessions, surveyResponsesFeedbackPart }] = usePaginatedQuery(getGroupedSurveyResponses, { projectSlug, surveyId: survey.id }) - type QuestionObject = { - id: number - label: string - component: "singleResponse" | "multipleResponse" | "text" - props: { responses: { id: number; text: string }[] } - } - const transformQuestion = (question: TQuestion): QuestionObject => { - return { - id: question.id, - label: question.label.de, - component: question.component, - props: { - responses: question.props.responses.map((response) => { - return { - id: response.id, - text: response.text.de, - } - }), - }, - } - } - - const extractAndTransformQuestionsFromPages = (pages: TSurvey["pages"]): QuestionObject[] => { - const transformedArray: QuestionObject[] = [] - - pages.forEach((page) => { - if (!page.questions || page.questions.length === 0) return - - const transformedQuestions = page.questions - .map((question) => { - if (!("responses" in question.props)) return - return transformQuestion(question) - }) - .filter(Boolean) - transformedArray.push(...transformedQuestions) - }) - return transformedArray - } - - const transformDeletedQuestions = (questions: TQuestion[]): QuestionObject[] => { - const transformedArray: QuestionObject[] = [] - const transformedQuestions = questions - .map((question) => { - if (!("responses" in question.props)) return - return transformQuestion(question) - }) - .filter(Boolean) - transformedArray.push(...transformedQuestions) - return transformedArray - } - const feedbackDefinition = getFeedbackDefinitionBySurveySlug(survey.slug) const surveyDefinition = getSurveyDefinitionBySurveySlug(survey.slug) const responseConfig = getResponseConfigBySurveySlug(survey.slug) @@ -132,16 +85,16 @@ export const Survey = () => { }) // get all questions from surveyDefinition and transform them - let surveyDefinitionArray = extractAndTransformQuestionsFromPages( + const surveyDefinitionArrayWithLatestQuestions = extractAndTransformQuestionsFromPages( surveyDefinition.pages as TSurvey["pages"], ) // add th deleted questions to the array and transform them - if (surveyDefinition.deletedQuestions) { - surveyDefinitionArray = surveyDefinitionArray.concat( - transformDeletedQuestions(surveyDefinition.deletedQuestions), - ) - } + const surveyDefinitionArray = surveyDefinition.deletedQuestions + ? surveyDefinitionArrayWithLatestQuestions.concat( + transformDeletedQuestions(surveyDefinition.deletedQuestions), + ) + : surveyDefinitionArrayWithLatestQuestions const groupedSurveyResponseData = rawData.map((r) => { const questionId = Object.keys(r)[0] @@ -159,7 +112,7 @@ export const Survey = () => { return { questionLabel: question.label, data } }) - const handleCopyButtonClick = async () => { + const handleCopyChartDataButtonClick = async () => { await navigator.clipboard.writeText(JSON.stringify(groupedSurveyResponseData)) } @@ -247,11 +200,20 @@ export const Survey = () => { -

- -

+ + Fragen der Beteiligung als CSV herunterladen + + + Antworten der Beteiligung als CSV herunterladen + + + Ergebnisse der Beteiligung als CSV herunterladen + +
diff --git a/src/pages/api/survey/[surveyId]/_shared.ts b/src/pages/api/survey/[surveyId]/_shared.ts new file mode 100644 index 000000000..d4bd2a75d --- /dev/null +++ b/src/pages/api/survey/[surveyId]/_shared.ts @@ -0,0 +1,66 @@ +import { createObjectCsvStringifier } from "csv-writer" +import { NextApiRequest, NextApiResponse } from "next" +import { api } from "src/blitz-server" +import { getSession } from "@blitzjs/auth" +import { AuthorizationError } from "blitz" +import { ZodError } from "zod" +import dbGetSurvey from "src/surveys/queries/getSurvey" +import { Survey } from "db" + +const DEBUG = false + +export const getSurvey = async ( + req: NextApiRequest, + res: NextApiResponse, +): Promise => { + await api(() => null) + + const err = (status: number, message: string) => { + res.status(status).json({ error: true, status: status, message }) + res.end() + } + + const session = await getSession(req, res) + + let survey + try { + // @ts-ignore + survey = await dbGetSurvey({ id: Number(req.query.surveyId) }, { session }) + } catch (e) { + if (e instanceof AuthorizationError) { + err(403, "Forbidden") + } + // @ts-ignore + if (e.code === "P2025" || e instanceof ZodError) { + err(404, "Not Found") + } + + console.error(e) + err(500, "Internal Server Error") + + return + } + + return survey +} + +export const sendCsv = ( + res: NextApiResponse, + headers: { id: string; title: string }[], + data: Record[], + filename: string, +) => { + const csvStringifier = createObjectCsvStringifier({ + header: headers, + fieldDelimiter: ";", + alwaysQuote: true, + }) + const csvString = csvStringifier.getHeaderString() + csvStringifier.stringifyRecords(data) + if (DEBUG) { + res.setHeader("Content-Type", "text/plain") + } else { + res.setHeader("Content-Type", "text/csv") + res.setHeader("Content-Disposition", `attachment; filename=${filename}`) + } + res.send(csvString) +} diff --git a/src/pages/api/survey/[surveyId]/questions.ts b/src/pages/api/survey/[surveyId]/questions.ts new file mode 100644 index 000000000..049893cc2 --- /dev/null +++ b/src/pages/api/survey/[surveyId]/questions.ts @@ -0,0 +1,43 @@ +import { NextApiRequest, NextApiResponse } from "next" +import { TSurvey } from "src/survey-public/components/types" +import { getSurvey, sendCsv } from "./_shared" +import { getSurveyDefinitionBySurveySlug } from "src/survey-public/utils/getConfigBySurveySlug" + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const survey = await getSurvey(req, res) + if (!survey) return + + const surveyDefinition = getSurveyDefinitionBySurveySlug(survey.slug) + + const headers = [ + { id: "id", title: "frage_id" }, + { id: "type", title: "typ" }, + { id: "question", title: "frage" }, + ] + + const types = { + singleResponse: "single", + multipleResponse: "multi", + } + + type Question = { id: number | string; type: string; question: string } + let data: Question[] = [] + const addQuestions = (definition: TSurvey) => { + definition.pages.forEach((page) => { + if (!page.questions) return + page.questions.forEach(({ id, component, label }) => { + // @ts-ignore + data.push({ id, type: types[component] || component, question: label.de }) + }) + }) + } + + addQuestions(surveyDefinition) + + // for now we only want questions, not feedback part + // in case we want to include the feedack part we cvan uncomment that line + // @ts-ignore + // addQuestions(feedbackDefinition) + + sendCsv(res, headers, data, "fragen.csv") +} diff --git a/src/pages/api/survey/[surveyId]/responses.ts b/src/pages/api/survey/[surveyId]/responses.ts new file mode 100644 index 000000000..92becea3e --- /dev/null +++ b/src/pages/api/survey/[surveyId]/responses.ts @@ -0,0 +1,45 @@ +import { NextApiRequest, NextApiResponse } from "next" +import { TSurvey } from "src/survey-public/components/types" +import { getSurveyDefinitionBySurveySlug } from "src/survey-public/utils/getConfigBySurveySlug" +import { getSurvey, sendCsv } from "./_shared" + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const survey = await getSurvey(req, res) + if (!survey) return + const surveyDefinition = getSurveyDefinitionBySurveySlug(survey.slug) + + const headers = [ + { id: "questionId", title: "frage_id" }, + { id: "responseId", title: "antwort_id" }, + { id: "text", title: "antwort" }, + ] + + type Question = { questionId: number | string; responseId: number | string; text: string } + let data: Question[] = [] + const addQuestions = (definition: TSurvey) => { + definition.pages.forEach((page) => { + if (!page.questions) return + page.questions.forEach((question) => { + if (!["singleResponse", "multipleResponse"].includes(question.component)) return + // @ts-ignore + question.props.responses.forEach((response) => { + data.push({ + questionId: question.id, + responseId: response.id, + text: response.text.de, + }) + }) + }) + }) + } + + // @ts-ignore + addQuestions(surveyDefinition) + + // for now we only want questions, not feedback part + // in case we want to include the feedack part we cvan uncomment that line + // @ts-ignore + // addQuestions(feedbackDefinition) + + sendCsv(res, headers, data, "antworten.csv") +} diff --git a/src/pages/api/survey/[surveyId]/results.ts b/src/pages/api/survey/[surveyId]/results.ts new file mode 100644 index 000000000..c35e12b0c --- /dev/null +++ b/src/pages/api/survey/[surveyId]/results.ts @@ -0,0 +1,86 @@ +import db from "db" +import { NextApiRequest, NextApiResponse } from "next" +import { + getFeedbackDefinitionBySurveySlug, + getSurveyDefinitionBySurveySlug, +} from "src/survey-public/utils/getConfigBySurveySlug" +import { getSurvey, sendCsv } from "./_shared" + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const survey = await getSurvey(req, res) + if (!survey) return + + const surveyDefinition = getSurveyDefinitionBySurveySlug(survey.slug) + const feedbackDefinition = getFeedbackDefinitionBySurveySlug(survey.slug) + const surveys = Object.fromEntries([surveyDefinition, feedbackDefinition].map((o) => [o.part, o])) + const questions = {} + + Object.values(surveys).forEach((survey) => { + survey.pages.forEach((page) => { + if (!page.questions) return + page.questions.forEach((question) => { + // @ts-ignore + if (["singleResponse", "multipleResponse"].includes(question.component)) { + // @ts-ignore + question.responses = Object.fromEntries(question.props.responses.map((r) => [r.id, r])) + } + // @ts-ignore + questions[question.id] = question + }) + }) + }) + + const surveySessions = await db.surveySession.findMany({ + where: { surveyId: survey.id }, + include: { responses: { where: { surveyPart: 1 } } }, + }) + + // for now we only want questions, not feedback part + // in case we want to include the feedack part we cvan uncomment these lines + + const headers = [ + { id: "createdAt", title: "datum" }, + { id: "sessionId", title: "sitzung_id" }, + { id: "questionId", title: "frage_id" }, + { id: "responseId", title: "ergebnis_antwort_id" }, + // { id: "responseText", title: "responseText" }, + // { id: "responseData", title: "responseData" }, + ] + + type Result = { + createdAt: string + sessionId: string + questionId: string + responseId?: string + // responseText?: string + // responseData?: string + } + + const csvData: Result[] = [] + + // as we only want to include the latest questions in the export we need to check if the question is in the array of latest questions; in the frm7 project we deleted questions after the survey was live + const deletedQuestionIds = surveyDefinition.deletedQuestions?.map((q) => q.id) + + surveySessions.forEach((surveySession) => { + const { id, createdAt, responses } = surveySession + + responses.forEach(({ data }) => { + // @ts-expect-error + data = JSON.parse(data) + Object.entries(data).map(([questionId, responseData]) => { + if (!deletedQuestionIds || !deletedQuestionIds?.includes(Number(questionId))) { + let row: Result = { + createdAt: createdAt.toLocaleDateString("de-DE"), + sessionId: String(id), + questionId: "n/a", + } + const responseId = responseData + row = { ...row, questionId, responseId } + csvData.push(row) + } + }) + }) + }) + + sendCsv(res, headers, csvData, "ergebnisse.csv") +} diff --git a/src/survey-responses/utils/format-survey-questions.ts b/src/survey-responses/utils/format-survey-questions.ts new file mode 100644 index 000000000..56ae5a800 --- /dev/null +++ b/src/survey-responses/utils/format-survey-questions.ts @@ -0,0 +1,55 @@ +import { TQuestion, TSurvey } from "src/survey-public/components/types" + +type QuestionObject = { + id: number + label: string + component: "singleResponse" | "multipleResponse" | "text" + props: { responses: { id: number; text: string }[] } +} + +const transformQuestion = (question: TQuestion): QuestionObject => { + return { + id: question.id, + label: question.label.de, + component: question.component, + props: { + responses: question.props.responses.map((response) => { + return { + id: response.id, + text: response.text.de, + } + }), + }, + } +} + +export const extractAndTransformQuestionsFromPages = ( + pages: TSurvey["pages"], +): QuestionObject[] => { + const transformedArray: QuestionObject[] = [] + + pages.forEach((page) => { + if (!page.questions || page.questions.length === 0) return + + const transformedQuestions = page.questions + .map((question) => { + if (!("responses" in question.props)) return + return transformQuestion(question) + }) + .filter(Boolean) + transformedArray.push(...transformedQuestions) + }) + return transformedArray +} + +export const transformDeletedQuestions = (questions: TQuestion[]): QuestionObject[] => { + const transformedArray: QuestionObject[] = [] + const transformedQuestions = questions + .map((question) => { + if (!("responses" in question.props)) return + return transformQuestion(question) + }) + .filter(Boolean) + transformedArray.push(...transformedQuestions) + return transformedArray +}