Merge branch 'user-rights-management' of https://github.com/nowcommunity/nowdatabase into user-rights-management

This commit is contained in:
Tatu Laras
2025-06-11 14:16:23 +03:00
8 changed files with 169 additions and 35 deletions
+34 -1
View File
@@ -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 {
+47 -1
View File
@@ -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}
+4 -13
View File
@@ -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,
+11 -2
View File
@@ -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
+7
View File
@@ -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
}