mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Merge branch 'user-rights-management' of https://github.com/nowcommunity/nowdatabase into user-rights-management
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { Router } from 'express'
|
||||
import { getCrossSearchRawSql, parseAndValidateCrossSearchRouteParameters } from '../services/crossSearch'
|
||||
import {
|
||||
getCrossSearchRawSql,
|
||||
getCrossSearchLocalitiesRawSql,
|
||||
parseAndValidateCrossSearchRouteParameters,
|
||||
} from '../services/crossSearch'
|
||||
import { fixBigInt } from '../utils/common'
|
||||
import { format } from '@fast-csv/format'
|
||||
import { pipeline } from 'stream'
|
||||
@@ -41,6 +45,35 @@ router.get(`/all/:limit/:offset/:columnfilters/:sorting`, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.get(`/localities/:columnfilters/:sorting`, async (req, res) => {
|
||||
let validatedValues
|
||||
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)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return res.status(403).send({ error: error.message })
|
||||
return res.status(403).send('Unknown error')
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getCrossSearchLocalitiesRawSql(
|
||||
req.user,
|
||||
validatedValues.validatedColumnFilters,
|
||||
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')
|
||||
}
|
||||
})
|
||||
|
||||
router.get(`/export/:columnfilters/:sorting`, async (req, res) => {
|
||||
let validatedValues
|
||||
try {
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
ParsedCrossSearchRouteParameters,
|
||||
SortingState,
|
||||
ColumnFilter,
|
||||
SimplifiedLocality,
|
||||
} from '../../../frontend/src/shared/types'
|
||||
import { getCrossSearchFields, getFieldsOfTables, nowDb } from '../utils/db'
|
||||
import { generateCrossSearchSql } from './queries/crossSearchQuery'
|
||||
import { generateCrossSearchLocalitiesSql, generateCrossSearchSql } from './queries/crossSearchQuery'
|
||||
import { ValidationObject } from '../../../frontend/src/shared/validators/validator'
|
||||
import { validateCrossSearchRouteParams } from '../../../frontend/src/shared/validators/crossSearch'
|
||||
|
||||
@@ -126,6 +127,51 @@ export const getCrossSearchRawSql = async (
|
||||
return result
|
||||
}
|
||||
|
||||
export const getCrossSearchLocalitiesRawSql = async (
|
||||
user: User | undefined,
|
||||
columnFilters?: ColumnFilter[],
|
||||
sorting?: SortingState[]
|
||||
) => {
|
||||
let orderBy: string | undefined
|
||||
let descendingOrder: boolean = false
|
||||
if (!sorting || sorting.length === 0) {
|
||||
orderBy = undefined
|
||||
} else {
|
||||
orderBy = sorting[0].id
|
||||
descendingOrder = sorting[0].desc
|
||||
}
|
||||
|
||||
let convertedOrderBy = undefined
|
||||
if (orderBy) {
|
||||
convertedOrderBy = convertFilterIdToFieldName(orderBy)
|
||||
if (!getCrossSearchFields().includes(convertedOrderBy)) throw new Error('orderBy was not a valid column id.')
|
||||
}
|
||||
|
||||
const convertedColumnFilters = []
|
||||
if (columnFilters) {
|
||||
const allowedColumns = getCrossSearchFields()
|
||||
for (const filter of columnFilters) {
|
||||
const convertedId = convertFilterIdToFieldName(filter.id)
|
||||
if (!allowedColumns.includes(convertedId)) throw new Error('columnFilters has an invalid column id.')
|
||||
convertedColumnFilters.push({ id: convertedId, value: filter.value })
|
||||
}
|
||||
}
|
||||
|
||||
const showAll = user ? [Role.Admin, Role.EditUnrestricted].includes(user.role) : false
|
||||
const allowedLocalities = user ? await getAllowedLocalities(user) : []
|
||||
|
||||
const sql = generateCrossSearchLocalitiesSql(
|
||||
showAll,
|
||||
allowedLocalities,
|
||||
convertedColumnFilters,
|
||||
convertedOrderBy,
|
||||
descendingOrder
|
||||
)
|
||||
|
||||
const result: Partial<SimplifiedLocality>[] = await nowDb.$queryRaw(sql)
|
||||
return result
|
||||
}
|
||||
|
||||
export const parseAndValidateCrossSearchRouteParameters = (parameters: CrossSearchRouteParameters) => {
|
||||
const { limit, offset, columnFilters, sorting } = parameters
|
||||
let parsedLimit
|
||||
|
||||
@@ -15,7 +15,7 @@ const generateWhereClause = (showAll: boolean, allowedLocalities: Array<number>,
|
||||
else {
|
||||
const conditions: Prisma.Sql[] = []
|
||||
for (const filter of columnFilters) {
|
||||
const newQuery = Prisma.sql`${Prisma.raw(filter.id)} = ${filter.value}`
|
||||
const newQuery = Prisma.sql`${Prisma.raw(filter.id)} LIKE ${'%' + filter.value + '%'}`
|
||||
conditions.push(newQuery)
|
||||
}
|
||||
|
||||
@@ -256,3 +256,29 @@ export const generateCrossSearchSql = (
|
||||
${offset ? Prisma.sql`OFFSET ${offset}` : Prisma.empty}
|
||||
`
|
||||
}
|
||||
|
||||
export const generateCrossSearchLocalitiesSql = (
|
||||
showAll: boolean,
|
||||
allowedLocalities: Array<number>,
|
||||
columnFilters: ColumnFilter[],
|
||||
orderBy: string | undefined,
|
||||
descendingOrder: boolean
|
||||
) => {
|
||||
const whereClause = generateWhereClause(showAll, allowedLocalities, columnFilters)
|
||||
|
||||
return Prisma.sql`
|
||||
SELECT
|
||||
-- now_loc fields
|
||||
now_loc.lid,
|
||||
now_loc.loc_name,
|
||||
now_loc.dec_lat,
|
||||
now_loc.dec_long
|
||||
FROM com_species
|
||||
INNER JOIN now_ls ON com_species.species_id = now_ls.species_id
|
||||
INNER JOIN now_loc ON now_ls.lid = now_loc.lid
|
||||
${whereClause}
|
||||
GROUP BY now_loc.lid
|
||||
ORDER BY
|
||||
${orderBy ? Prisma.raw(orderBy) : Prisma.sql`now_loc.lid`} ${descendingOrder ? Prisma.sql`DESC` : Prisma.empty}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useMemo } from 'react'
|
||||
import { type MRT_ColumnDef } from 'material-react-table'
|
||||
import { CrossSearch } from '@/shared/types'
|
||||
import { TableView } from '../TableView/TableView'
|
||||
import { useGetAllCrossSearchQuery } from '@/redux/crossSearchReducer'
|
||||
import { useGetAllCrossSearchQuery, useGetAllCrossSearchLocalitiesQuery } from '@/redux/crossSearchReducer'
|
||||
import { usePageContext } from '../Page'
|
||||
import { LocalitiesMap } from '../Map/LocalitiesMap'
|
||||
|
||||
export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: CrossSearch) => void }) => {
|
||||
const { sqlLimit, sqlOffset, sqlColumnFilters, sqlOrderBy } = usePageContext()
|
||||
@@ -14,6 +15,11 @@ export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: Cros
|
||||
sorting: sqlOrderBy,
|
||||
})
|
||||
|
||||
const { data: localitiesData, isFetching: localitiesFetching } = useGetAllCrossSearchLocalitiesQuery({
|
||||
columnFilters: sqlColumnFilters,
|
||||
sorting: sqlOrderBy,
|
||||
})
|
||||
|
||||
// Show/Hide columns in the following order
|
||||
const columns = useMemo<MRT_ColumnDef<CrossSearch>[]>(
|
||||
() => [
|
||||
@@ -800,19 +806,25 @@ export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: Cros
|
||||
}
|
||||
|
||||
return (
|
||||
<TableView<CrossSearch>
|
||||
title="Locality-Species-Cross-Search"
|
||||
selectorFn={selectorFn}
|
||||
checkRowRestriction={checkRowRestriction}
|
||||
idFieldName="lid_now_loc"
|
||||
columns={columns}
|
||||
isFetching={isFetching}
|
||||
visibleColumns={visibleColumns}
|
||||
data={crossSearchQueryData}
|
||||
url="crosssearch"
|
||||
enableColumnFilterModes={true}
|
||||
serverSidePagination={true}
|
||||
isCrossSearchTable={true}
|
||||
/>
|
||||
<>
|
||||
<LocalitiesMap
|
||||
localitiesQueryData={localitiesData}
|
||||
localitiesQueryIsFetching={localitiesFetching || isFetching}
|
||||
/>
|
||||
<TableView<CrossSearch>
|
||||
title="Locality-Species-Cross-Search"
|
||||
selectorFn={selectorFn}
|
||||
checkRowRestriction={checkRowRestriction}
|
||||
idFieldName="lid_now_loc"
|
||||
columns={columns}
|
||||
isFetching={isFetching}
|
||||
visibleColumns={visibleColumns}
|
||||
data={crossSearchQueryData}
|
||||
url="crosssearch"
|
||||
enableColumnFilterModes={true}
|
||||
serverSidePagination={true}
|
||||
isCrossSearchTable={true}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useMemo } from 'react'
|
||||
import { MRT_TableInstance, type MRT_ColumnDef, MRT_RowData } from 'material-react-table'
|
||||
import { useGetAllLocalitiesQuery, useGetLocalitySpeciesListMutation } from '../../redux/localityReducer'
|
||||
import { Locality } from '@/shared/types'
|
||||
import { SimplifiedLocality, Locality } from '@/shared/types'
|
||||
import { TableView } from '../TableView/TableView'
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
import { LocalitiesMap } from '../Map/LocalitiesMap'
|
||||
import { generateKml } from '@/util/kml'
|
||||
import { generateSvg } from '../Map/generateSvg'
|
||||
import { usePageContext } from '../Page'
|
||||
|
||||
const decimalCount = (num: number) => {
|
||||
const numAsString = num.toString()
|
||||
@@ -20,6 +21,7 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
|
||||
const { data: localitiesQueryData, isFetching: localitiesQueryIsFetching } = useGetAllLocalitiesQuery()
|
||||
const [getLocalitySpeciesList, { isLoading }] = useGetLocalitySpeciesListMutation()
|
||||
const notify = useNotify()
|
||||
const columnFilters = usePageContext()
|
||||
const columns = useMemo<MRT_ColumnDef<Locality>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -413,9 +415,17 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
|
||||
return !!row.loc_status
|
||||
}
|
||||
|
||||
// Filter localities for the map component
|
||||
const localityIds = columnFilters.idList as unknown as number[]
|
||||
const validIds = localityIds.filter(id => typeof id === 'number')
|
||||
const filteredLocalities = localitiesQueryData?.filter(locality => validIds.includes(locality.lid))
|
||||
|
||||
return (
|
||||
<>
|
||||
<LocalitiesMap localitiesQueryData={localitiesQueryData} localitiesQueryIsFetching={localitiesQueryIsFetching} />
|
||||
<LocalitiesMap
|
||||
localitiesQueryData={filteredLocalities as SimplifiedLocality[]}
|
||||
localitiesQueryIsFetching={localitiesQueryIsFetching}
|
||||
/>
|
||||
<TableView<Locality>
|
||||
title="Localities"
|
||||
selectorFn={selectorFn}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useGetLocalityDetailsQuery } from '../../redux/localityReducer'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import L, { LatLngExpression } from 'leaflet'
|
||||
import { borders } from './country_borders_WGS84'
|
||||
import { Button } from '@mui/material'
|
||||
import MapIcon from '@mui/icons-material/Map'
|
||||
import { Locality } from '@/shared/types/data.js'
|
||||
import L, { LatLngExpression } from 'leaflet'
|
||||
import { SimplifiedLocality } from '@/shared/types/data.js'
|
||||
import { usePageContext } from '../Page'
|
||||
import { skipToken } from '@reduxjs/toolkit/query'
|
||||
import './leaflet.markercluster.js'
|
||||
@@ -17,7 +17,7 @@ import '../../styles/LocalityMap.css'
|
||||
import northarrow from './images/north-arrow.png'
|
||||
|
||||
interface Props {
|
||||
localitiesQueryData?: Locality[]
|
||||
localitiesQueryData?: SimplifiedLocality[]
|
||||
localitiesQueryIsFetching: boolean
|
||||
}
|
||||
|
||||
@@ -119,16 +119,7 @@ export const LocalitiesMap = ({ localitiesQueryData, localitiesQueryIsFetching }
|
||||
// with no module exports that extends 'L' when imported
|
||||
const newMarkers: Layer = cluster ? L.markerClusterGroup() : L.layerGroup()
|
||||
|
||||
const localityIds = columnFilters.idList as unknown as number[]
|
||||
const filterApplied = localityIds.length > 0 && localityIds.every(id => id === null)
|
||||
|
||||
const validIds = localityIds.filter(id => typeof id === 'number')
|
||||
|
||||
const filteredLocalities = !filterApplied
|
||||
? localitiesQueryData?.filter(locality => validIds.includes(locality.lid))
|
||||
: localitiesQueryData
|
||||
|
||||
filteredLocalities?.forEach(locality => {
|
||||
localitiesQueryData?.forEach(locality => {
|
||||
const options: CustomCircleMarkerOptions = {
|
||||
localityId: locality.lid,
|
||||
radius: 4,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MRT_ColumnFiltersState, MRT_SortingState } from 'material-react-table'
|
||||
import { api } from './api'
|
||||
import { CrossSearch } from '@/shared/types'
|
||||
import { CrossSearch, SimplifiedLocality } from '@/shared/types'
|
||||
|
||||
const crossSearchApi = api.injectEndpoints({
|
||||
endpoints: builder => ({
|
||||
@@ -13,7 +13,16 @@ const crossSearchApi = api.injectEndpoints({
|
||||
}),
|
||||
providesTags: result => (result ? [{ type: 'localities' }] : []),
|
||||
}),
|
||||
getAllCrossSearchLocalities: builder.query<
|
||||
SimplifiedLocality[],
|
||||
{ columnFilters: MRT_ColumnFiltersState; sorting: MRT_SortingState }
|
||||
>({
|
||||
query: ({ columnFilters, sorting }) => ({
|
||||
url: `/crosssearch/localities/${JSON.stringify(columnFilters)}/${JSON.stringify(sorting)}`,
|
||||
}),
|
||||
providesTags: result => (result ? [{ type: 'localities' }] : []),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const { useGetAllCrossSearchQuery } = crossSearchApi
|
||||
export const { useGetAllCrossSearchQuery, useGetAllCrossSearchLocalitiesQuery } = crossSearchApi
|
||||
|
||||
@@ -473,3 +473,10 @@ export type GeonamesJSON = {
|
||||
}
|
||||
|
||||
export type ParsedGeoname = Pick<Geoname, 'name' | 'countryName' | 'fclName' | 'adminName1' | 'lat' | 'lng'>
|
||||
|
||||
export type SimplifiedLocality = {
|
||||
lid: number
|
||||
dec_lat: number
|
||||
dec_long: number
|
||||
loc_name: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user