diff --git a/app/atoms/AdminHeader.tsx b/app/atoms/AdminHeader.tsx index b18298a..bc4d6dd 100644 --- a/app/atoms/AdminHeader.tsx +++ b/app/atoms/AdminHeader.tsx @@ -1,13 +1,14 @@ import styled from 'styled-components' export const AdminHeader = styled.div` - align-items: center; + align-items: flex-start; display: flex; justify-content: space-between; - height: 2.5rem; margin-bottom: 1.5rem; + min-height: 2.5rem; h1 { - line-height: 1; + line-height: 1.5; + padding: 0 1.5rem 0 0.5rem; } ` diff --git a/package.json b/package.json index ffc02f7..05bf8b0 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "ajv": "8.11.0", "bhala": "3.0.4", "cors": "2.8.5", + "csv-stringify": "6.2.3", "cuid": "2.1.8", "dayjs": "1.11.3", "dotenv": "16.0.1", @@ -92,7 +93,6 @@ "styled-components": "5.3.3", "type-fest": "2.13.1", "typescript": "4.7.4", - "xlsx": "0.17.4", "yup": "0.32.9", "zxcvbn": "4.4.2" }, diff --git a/pages/api/surveys/[surveyId]/entries/download.ts b/pages/api/surveys/[surveyId]/entries/download.ts new file mode 100644 index 0000000..475ddd9 --- /dev/null +++ b/pages/api/surveys/[surveyId]/entries/download.ts @@ -0,0 +1,100 @@ +import { ApiError } from '@api/libs/ApiError' +import { prisma } from '@api/libs/prisma' +import { handleAuth } from '@api/middlewares/withAuth/handleAuth' +import { handleApiEndpointError } from '@common/helpers/handleApiEndpointError' +import { UserRole } from '@prisma/client' +import { TellMe } from '@schemas/1.0.0/TellMe' +import { stringify } from 'csv-stringify' +import { keys, uniq } from 'ramda' + +import type { NextApiRequest, NextApiResponse } from 'next' + +const ERROR_PATH = 'pages/api/surveys/[surveyId]/entries/download.ts' + +async function generateCsvFromObject(data: Array>): Promise { + return new Promise((resolve, reject) => { + stringify( + data, + { + header: true, + quoted_empty: true, + quoted_string: true, + }, + (err, dataAsCsv) => { + if (err) { + reject(err) + + return + } + + resolve(dataAsCsv) + }, + ) + }) +} + +export default async function SurveyEntriesDownloadEndpoint(req: NextApiRequest, res: NextApiResponse) { + switch (req.method) { + case 'GET': + try { + await handleAuth(req, res, [UserRole.ADMINISTRATOR, UserRole.MANAGER], true) + + const { surveyId } = req.query + if (typeof surveyId !== 'string') { + throw new ApiError('`surveyId` must be a string.', 422, true) + } + + const maybeSurvey = await prisma.survey.findUnique({ + where: { + id: surveyId, + }, + }) + if (maybeSurvey === null) { + throw new ApiError('Not found.', 404, true) + } + + const data = maybeSurvey.data as TellMe.Data + const dataAsFlatObject = data.entries.map(({ answers, id, openedAt, submittedAt }) => + answers + .filter(({ type }) => type !== 'file') + .reduce( + (prev, { question, rawValue }) => ({ + ...prev, + [question.value]: rawValue, + }), + { + /* eslint-disable sort-keys-fix/sort-keys-fix */ + ID: id, + 'Opened At': openedAt, + 'Submitted At': submittedAt, + /* eslint-enable sort-keys-fix/sort-keys-fix */ + } as Record, + ), + ) + const dataKeys = uniq(dataAsFlatObject.map(keys).flat()) + const consolidatedDataAsFlatObject = dataAsFlatObject.map(record => + dataKeys.reduce((_record, dataKey) => { + if (_record[dataKey] === undefined) { + return { + ..._record, + [dataKey]: undefined, + } + } + + return _record + }, record), + ) + const dataAsCsv = await generateCsvFromObject(consolidatedDataAsFlatObject) + + res.setHeader('Content-Type', 'text/csv') + res.status(200).send(dataAsCsv) + } catch (err) { + handleApiEndpointError(err, ERROR_PATH, res, true) + } + + return undefined + + default: + handleApiEndpointError(new ApiError('Method not allowed.', 405, true), ERROR_PATH, res, true) + } +} diff --git a/pages/api/surveys/[surveyId]/entries/index.ts b/pages/api/surveys/[surveyId]/entries/index.ts index 36ed180..8ea13f1 100644 --- a/pages/api/surveys/[surveyId]/entries/index.ts +++ b/pages/api/surveys/[surveyId]/entries/index.ts @@ -11,7 +11,7 @@ import cuid from 'cuid' import type { TellMe } from '@schemas/1.0.0/TellMe' import type { NextApiRequest, NextApiResponse } from 'next' -const ERROR_PATH = 'pages/api/surveys/[surveyId]/index.ts' +const ERROR_PATH = 'pages/api/surveys/[surveyId]/entries/index.ts' export default async function SurveyEntryIndexEndpoint(req: NextApiRequest, res: NextApiResponse) { switch (req.method) { diff --git a/pages/surveys/[id]/entries.tsx b/pages/surveys/[id]/entries.tsx index 441593c..b88742f 100644 --- a/pages/surveys/[id]/entries.tsx +++ b/pages/surveys/[id]/entries.tsx @@ -99,7 +99,7 @@ export default function SurveyEntryListPage() { return } - const maybeBody = await api.get('auth/one-time-token') + const maybeBody = await api.get('one-time-tokens/new') if (maybeBody === null || maybeBody.hasError) { return } @@ -109,19 +109,19 @@ export default function SurveyEntryListPage() { window.open(`${url}?mimeType=${mimeType}&oneTimeToken=${oneTimeToken}`, '') } - const exportEntries = async fileExtension => { + const exportEntries = async () => { if (isMounted()) { setIsDownloading(true) } - const maybeBody = await api.get(`auth/one-time-token`) + const maybeBody = await api.get(`one-time-tokens/new`) if (maybeBody === null || maybeBody.hasError) { return } const { oneTimeToken } = maybeBody.data - window.open(`/api/surveys/${surveyId}/download?fileExtension=${fileExtension}&oneTimeToken=${oneTimeToken}`) + window.open(`/api/surveys/${surveyId}/entries/download?oneTimeToken=${oneTimeToken}`) if (isMounted()) { setIsDownloading(false) @@ -245,7 +245,15 @@ export default function SurveyEntryListPage() { {survey.data.title} -