Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/src/api-tests/crossSearch/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ describe('Getting cross-search export', () => {
expect(response.body).toEqual({ error: 'ColumnFilters is not an array.' })
})

it('with invalid POST column filters type does not work', async () => {
const response = await send(`crosssearch/export`, 'POST', { columnFilters: '"not an array"', sorting: [] })
expect(response.status).toEqual(403)
expect(response.body).toEqual({ error: 'ColumnFilters is not an array.' })
})

it('with invalid column filters content does not work', async () => {
const { body: responseBody1, status: responseStatus1 } = await send(
`crosssearch/export/[{"id": "not_a_column", "value": "Dmanisi"}]/[]`,
Expand Down
25 changes: 25 additions & 0 deletions backend/src/api-tests/crossSearch/get.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ describe('Getting cross-search data', () => {
expect(response2.body.length).toEqual(10)
})

it('Requesting get all with POST body has correct data', async () => {
const { body, status } = await send<CrossSearch[]>(`crosssearch/all`, 'POST', {
limit: 20,
offset: 0,
columnFilters: [{ id: 'country', value: 'Spain' }],
sorting: [{ id: 'loc_name', desc: false }],
})

expect(status).toEqual(200)
expect(body).toHaveLength(10)
expect(body.every(row => row.country === 'Spain')).toBe(true)
})

it('Requesting get all with column filters has correct data', async () => {
const { body: responseBody1, status: responseStatus1 } = await send<CrossSearch[]>(
`crosssearch/all/20/0/[{"id": "lid_now_loc", "value": "21050"}]/[]`,
Expand Down Expand Up @@ -132,6 +145,18 @@ describe('Getting cross-search data', () => {
expect(lastCountries).toEqual(['Japan', 'Japan', 'Japan', 'Japan', 'Japan'])
})

it('Requesting localities with POST body has correct data', async () => {
const legacyResponse = await send(`crosssearch/localities/[{"id": "country", "value": "Spain"}]/[]`, 'GET')
const { body, status } = await send(`crosssearch/localities`, 'POST', {
columnFilters: [{ id: 'country', value: 'Spain' }],
sorting: [],
})

expect(status).toEqual(200)
expect(body.length).toBeGreaterThan(0)
expect(body).toEqual(legacyResponse.body)
})

it('Requesting get all with column filtering does not allow invalid arrays or column names', async () => {
const { body: responseBody1, status: responseStatus1 } = await send<CrossSearch[]>(
`crosssearch/all/20/0/[{"id": "invalid_column", "value": "21050"}]/[]`,
Expand Down
138 changes: 84 additions & 54 deletions backend/src/routes/crossSearch.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Router } from 'express'
import { Request, Response, Router } from 'express'
import {
getCrossSearchRawSql,
getCrossSearchLocalitiesRawSql,
parseAndValidateCrossSearchRouteParameters,
CrossSearchRequestParameters,
} from '../services/crossSearch'
import { fixBigInt } from '../utils/common'
import { format, FormatterRow, FormatterRowTransformFunction } from 'fast-csv'
Expand Down Expand Up @@ -32,82 +33,101 @@ const transformFunction = (row: CrossSearch & { full_count?: number }) => {
return transformedRow
}

router.get(`/all/:limit/:offset/:columnfilters/:sorting`, async (req, res) => {
let validatedValues
const getValidatedValues = (parameters: CrossSearchRequestParameters) => {
const { validationErrors, ...values } = parseAndValidateCrossSearchRouteParameters(parameters)
const validatedValues = values
if (validationErrors.length > 0) {
return { validationErrors }
}
return { validatedValues }
}

const handleCrossSearchValidationError = (error: unknown, res: Response) => {
if (error instanceof Error) return res.status(403).send({ error: error.message })
return res.status(403).send('Unknown error')
}

const sendCrossSearchRows = async (req: Request, res: Response, parameters: CrossSearchRequestParameters) => {
let parsedValues
try {
const { validationErrors, ...values } = parseAndValidateCrossSearchRouteParameters({
limit: req.params.limit,
offset: req.params.offset,
columnFilters: req.params.columnfilters,
sorting: req.params.sorting,
})
validatedValues = values
if (validationErrors.length > 0) {
return res.status(403).send(validationErrors)
}
parsedValues = getValidatedValues(parameters)
} catch (error) {
if (error instanceof Error) return res.status(403).send({ error: error.message })
return res.status(403).send('Unknown error')
return handleCrossSearchValidationError(error, res)
}
if ('validationErrors' in parsedValues) {
return res.status(403).send(parsedValues.validationErrors)
}

try {
const result = await getCrossSearchRawSql(
req.user,
validatedValues.validatedLimit,
validatedValues.validatedOffset,
validatedValues.validatedColumnFilters,
validatedValues.validatedSorting
parsedValues.validatedValues.validatedLimit,
parsedValues.validatedValues.validatedOffset,
parsedValues.validatedValues.validatedColumnFilters,
parsedValues.validatedValues.validatedSorting
)
return res.status(200).send(fixBigInt(result))
} catch (error) {
if (error instanceof Error) return res.status(403).send({ error: error.message })
return res.status(403).send('Unknown error')
return handleCrossSearchValidationError(error, res)
}
})
}

router.get(`/localities/:columnfilters/:sorting`, async (req, res) => {
let validatedValues
const sendCrossSearchLocalities = async (req: Request, res: Response, parameters: CrossSearchRequestParameters) => {
let parsedValues
try {
const { validationErrors, ...values } = parseAndValidateCrossSearchRouteParameters({
columnFilters: req.params.columnfilters,
sorting: req.params.sorting,
})
validatedValues = values
if (validationErrors.length > 0) {
return res.status(403).send(validationErrors)
}
parsedValues = getValidatedValues(parameters)
} catch (error) {
if (error instanceof Error) return res.status(403).send({ error: error.message })
return res.status(403).send('Unknown error')
return handleCrossSearchValidationError(error, res)
}
if ('validationErrors' in parsedValues) {
return res.status(403).send(parsedValues.validationErrors)
}

try {
const result = await getCrossSearchLocalitiesRawSql(
req.user,
validatedValues.validatedColumnFilters,
validatedValues.validatedSorting
parsedValues.validatedValues.validatedColumnFilters,
parsedValues.validatedValues.validatedSorting
)
return res.status(200).send(fixBigInt(result))
} catch (error) {
if (error instanceof Error) return res.status(403).send({ error: error.message })
return res.status(403).send('Unknown error')
return handleCrossSearchValidationError(error, res)
}
}

router.post(`/all`, async (req, res) => {
return sendCrossSearchRows(req, res, req.body as CrossSearchRequestParameters)
})

router.get(`/export/:columnfilters/:sorting`, async (req, res) => {
let validatedValues
router.get(`/all/:limit/:offset/:columnfilters/:sorting`, async (req, res) => {
return sendCrossSearchRows(req, res, {
limit: req.params.limit,
offset: req.params.offset,
columnFilters: req.params.columnfilters,
sorting: req.params.sorting,
})
})

router.post(`/localities`, async (req, res) => {
return sendCrossSearchLocalities(req, res, req.body as CrossSearchRequestParameters)
})

router.get(`/localities/:columnfilters/:sorting`, async (req, res) => {
return sendCrossSearchLocalities(req, res, {
columnFilters: req.params.columnfilters,
sorting: req.params.sorting,
})
})

const streamCrossSearchExport = async (req: Request, res: Response, parameters: CrossSearchRequestParameters) => {
let parsedValues
try {
const { validationErrors, ...values } = parseAndValidateCrossSearchRouteParameters({
columnFilters: req.params.columnfilters,
sorting: req.params.sorting,
})
validatedValues = values
if (validationErrors.length > 0) {
return res.status(403).send(validationErrors)
}
parsedValues = getValidatedValues(parameters)
} catch (error) {
if (error instanceof Error) return res.status(403).send({ error: error.message })
return res.status(403).send('Unknown error')
return handleCrossSearchValidationError(error, res)
}
if ('validationErrors' in parsedValues) {
return res.status(403).send(parsedValues.validationErrors)
}

let dataArray: Partial<CrossSearch[][]>
Expand All @@ -116,12 +136,11 @@ router.get(`/export/:columnfilters/:sorting`, async (req, res) => {
req.user,
undefined,
undefined,
validatedValues.validatedColumnFilters,
validatedValues.validatedSorting
parsedValues.validatedValues.validatedColumnFilters,
parsedValues.validatedValues.validatedSorting
)) as Partial<CrossSearch[][]>
} catch (error) {
if (error instanceof Error) return res.status(403).send({ error: error.message })
return res.status(403).send('Unknown error')
return handleCrossSearchValidationError(error, res)
}

res.on('finish', () => {
Expand Down Expand Up @@ -163,6 +182,17 @@ router.get(`/export/:columnfilters/:sorting`, async (req, res) => {
}

return stream.end()
}

router.post(`/export`, async (req, res) => {
return streamCrossSearchExport(req, res, req.body as CrossSearchRequestParameters)
})

router.get(`/export/:columnfilters/:sorting`, async (req, res) => {
return streamCrossSearchExport(req, res, {
columnFilters: req.params.columnfilters,
sorting: req.params.sorting,
})
})

export default router
42 changes: 27 additions & 15 deletions backend/src/services/crossSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ import { generateCrossSearchLocalitiesSql, generateCrossSearchSql } from './quer
import { ValidationObject } from '../../../frontend/src/shared/validators/validator'
import { validateCrossSearchRouteParams } from '../../../frontend/src/shared/validators/crossSearch'

export type CrossSearchRequestParameters = {
limit?: string | number
offset?: string | number
columnFilters: unknown
sorting: unknown
}

const getAllowedLocalities = async (user: User) => {
const usersProjects = await nowDb.now_proj_people.findMany({
where: { initials: user.initials },
Expand Down Expand Up @@ -195,22 +202,27 @@ export const getCrossSearchLocalitiesRawSql = async (
return result
}

export const parseAndValidateCrossSearchRouteParameters = (parameters: CrossSearchRouteParameters) => {
const parseOptionalNumber = (value: string | number | undefined, name: string) => {
if (value === undefined) return undefined
const parsedValue = typeof value === 'number' ? value : parseInt(value)
if (isNaN(parsedValue)) throw new Error(`${name} is not a number.`)
return parsedValue
}

const parseArrayParameter = (value: unknown, name: string) => {
const parsedValue: unknown = typeof value === 'string' ? (JSON.parse(value) as unknown) : value
if (!Array.isArray(parsedValue)) throw new Error(`${name} is not an array.`)
return parsedValue as unknown[]
}

export const parseAndValidateCrossSearchRouteParameters = (
parameters: CrossSearchRouteParameters | CrossSearchRequestParameters
) => {
const { limit, offset, columnFilters, sorting } = parameters
let parsedLimit
let parsedOffset
if (limit) {
parsedLimit = parseInt(limit)
if (isNaN(parsedLimit)) throw new Error('Limit is not a number.')
}
if (offset) {
parsedOffset = parseInt(offset)
if (isNaN(parsedOffset)) throw new Error('Offset is not a number.')
}
const parsedColumnFilters = JSON.parse(columnFilters) as unknown
if (!Array.isArray(parsedColumnFilters)) throw new Error('ColumnFilters is not an array.')
const parsedSorting = JSON.parse(sorting) as unknown
if (!Array.isArray(parsedSorting)) throw new Error('Sorting is not an array.')
const parsedLimit = parseOptionalNumber(limit, 'Limit')
const parsedOffset = parseOptionalNumber(offset, 'Offset')
const parsedColumnFilters = parseArrayParameter(columnFilters, 'ColumnFilters')
const parsedSorting = parseArrayParameter(sorting, 'Sorting')

const parsedParameters = {
limit: parsedLimit,
Expand Down
14 changes: 10 additions & 4 deletions frontend/src/components/CrossSearch/CrossSearchExportMenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,20 @@ export const CrossSearchExportMenuItem = ({ handleClose }: { handleClose: () =>

const fetchCSVFile = async () => {
setLoading(true)
const URIColumnFilters = encodeURIComponent(JSON.stringify(sqlColumnFilters))
const URIOrderBy = encodeURIComponent(JSON.stringify(sqlOrderBy))

try {
await downloadExportFileWithProgress({
url: `${BACKEND_URL}/crosssearch/export/${URIColumnFilters}/${URIOrderBy}`,
url: `${BACKEND_URL}/crosssearch/export`,
filename,
fetchOptions,
fetchOptions: {
...fetchOptions,
method: 'POST',
headers: {
...fetchOptions.headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({ columnFilters: sqlColumnFilters, sorting: sqlOrderBy }),
},
notify,
setNotificationMessage,
startMessage: 'Generating cross-search CSV export...',
Expand Down
Loading
Loading