mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Address Darwin Core export review comments
This commit is contained in:
@@ -12,19 +12,10 @@ import { AccessError, requireOneOf } from '../middlewares/authorizer'
|
|||||||
import { deleteLocality, writeLocality } from '../services/write/locality'
|
import { deleteLocality, writeLocality } from '../services/write/locality'
|
||||||
import { buildDwcLocalityArchiveZipBuffer } from '../services/dwcArchiveExportLocalities'
|
import { buildDwcLocalityArchiveZipBuffer } from '../services/dwcArchiveExportLocalities'
|
||||||
import { currentDateAsString } from '../../../frontend/src/shared/currentDateAsString'
|
import { currentDateAsString } from '../../../frontend/src/shared/currentDateAsString'
|
||||||
|
import { parseNumericIds } from './utils/exportFilters'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
const parseNumericIds = (value: unknown): number[] | undefined => {
|
|
||||||
if (value === undefined) return undefined
|
|
||||||
if (!Array.isArray(value)) throw new Error('ids must be an array.')
|
|
||||||
return value.map(id => {
|
|
||||||
const parsed = typeof id === 'number' ? id : typeof id === 'string' ? parseInt(id, 10) : NaN
|
|
||||||
if (!Number.isInteger(parsed)) throw new Error('ids must contain only integers.')
|
|
||||||
return parsed
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/all', async (req, res) => {
|
router.get('/all', async (req, res) => {
|
||||||
const localities = await getAllLocalities(req.user)
|
const localities = await getAllLocalities(req.user)
|
||||||
return res.status(200).send(fixBigInt(localities))
|
return res.status(200).send(fixBigInt(localities))
|
||||||
@@ -45,7 +36,7 @@ router.post('/export/dwc-archive', requireOneOf([Role.Admin]), async (req, res)
|
|||||||
try {
|
try {
|
||||||
return await sendDwcArchive(parseNumericIds((req.body as { ids?: unknown }).ids), res)
|
return await sendDwcArchive(parseNumericIds((req.body as { ids?: unknown }).ids), res)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(403).send({ error: error instanceof Error ? error.message : 'Invalid export filters.' })
|
return res.status(400).send({ error: error instanceof Error ? error.message : 'Invalid export filters.' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const resolveOccurrenceKeysForExport = async (req: Request): Promise<DwcOccurren
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleExportFilterError = (error: unknown, res: Response) => {
|
const handleExportFilterError = (error: unknown, res: Response) => {
|
||||||
return res.status(403).send({ error: error instanceof Error ? error.message : 'Invalid export filters.' })
|
return res.status(400).send({ error: error instanceof Error ? error.message : 'Invalid export filters.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/export/dwc-archive/progress/:exportId', requireOneOf([Role.Admin]), (req, res) => {
|
router.get('/export/dwc-archive/progress/:exportId', requireOneOf([Role.Admin]), (req, res) => {
|
||||||
|
|||||||
@@ -6,19 +6,10 @@ import { deleteSpecies, writeSpecies } from '../services/write/species'
|
|||||||
import { requireOneOf } from '../middlewares/authorizer'
|
import { requireOneOf } from '../middlewares/authorizer'
|
||||||
import { buildDwcArchiveZipBuffer } from '../services/dwcArchiveExport'
|
import { buildDwcArchiveZipBuffer } from '../services/dwcArchiveExport'
|
||||||
import { currentDateAsString } from '../../../frontend/src/shared/currentDateAsString'
|
import { currentDateAsString } from '../../../frontend/src/shared/currentDateAsString'
|
||||||
|
import { parseNumericIds } from './utils/exportFilters'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
const parseNumericIds = (value: unknown): number[] | undefined => {
|
|
||||||
if (value === undefined) return undefined
|
|
||||||
if (!Array.isArray(value)) throw new Error('ids must be an array.')
|
|
||||||
return value.map(id => {
|
|
||||||
const parsed = typeof id === 'number' ? id : typeof id === 'string' ? parseInt(id, 10) : NaN
|
|
||||||
if (!Number.isInteger(parsed)) throw new Error('ids must contain only integers.')
|
|
||||||
return parsed
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/all', async (_req, res) => {
|
router.get('/all', async (_req, res) => {
|
||||||
const species = await getAllSpecies()
|
const species = await getAllSpecies()
|
||||||
return res.status(200).send(fixBigInt(species))
|
return res.status(200).send(fixBigInt(species))
|
||||||
@@ -44,7 +35,7 @@ router.post('/export/dwc-archive', requireOneOf([Role.Admin]), async (req, res)
|
|||||||
try {
|
try {
|
||||||
return await sendDwcArchive(parseNumericIds((req.body as { ids?: unknown }).ids), res)
|
return await sendDwcArchive(parseNumericIds((req.body as { ids?: unknown }).ids), res)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(403).send({ error: error instanceof Error ? error.message : 'Invalid export filters.' })
|
return res.status(400).send({ error: error instanceof Error ? error.message : 'Invalid export filters.' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const parseNumericIds = (value: unknown): number[] | undefined => {
|
||||||
|
if (value === undefined) return undefined
|
||||||
|
if (!Array.isArray(value)) throw new Error('ids must be an array.')
|
||||||
|
|
||||||
|
return value.map(id => {
|
||||||
|
const parsed = typeof id === 'number' ? id : typeof id === 'string' && /^-?\d+$/.test(id) ? Number(id) : Number.NaN
|
||||||
|
|
||||||
|
if (!Number.isSafeInteger(parsed)) throw new Error('ids must contain only integers.')
|
||||||
|
return parsed
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -268,10 +268,12 @@ export const getFilteredCrossSearchOccurrenceKeys = async (
|
|||||||
)) as Array<Array<Partial<CrossSearch>>>
|
)) as Array<Array<Partial<CrossSearch>>>
|
||||||
|
|
||||||
const keysById = new Map<string, { lid: number; speciesId: number }>()
|
const keysById = new Map<string, { lid: number; speciesId: number }>()
|
||||||
for (const row of resultPages.flat()) {
|
for (const page of resultPages) {
|
||||||
if (typeof row.lid_now_loc !== 'number' || typeof row.species_id_com_species !== 'number') continue
|
for (const row of page) {
|
||||||
const key = { lid: row.lid_now_loc, speciesId: row.species_id_com_species }
|
if (typeof row.lid_now_loc !== 'number' || typeof row.species_id_com_species !== 'number') continue
|
||||||
keysById.set(`${key.lid}:${key.speciesId}`, key)
|
const key = { lid: row.lid_now_loc, speciesId: row.species_id_com_species }
|
||||||
|
keysById.set(`${key.lid}:${key.speciesId}`, key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { occurrenceKeys: [...keysById.values()] }
|
return { occurrenceKeys: [...keysById.values()] }
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ const occurrenceIdForRow = (lid: number, speciesId: number): string => `NOW:OCC:
|
|||||||
type LocalityForDwcDpExport = Parameters<typeof mapLocalityToMeasurementRows>[0]
|
type LocalityForDwcDpExport = Parameters<typeof mapLocalityToMeasurementRows>[0]
|
||||||
type OccurrenceForDwcDpExport = Parameters<typeof mapOccurrenceToOccurrenceRow>[0]
|
type OccurrenceForDwcDpExport = Parameters<typeof mapOccurrenceToOccurrenceRow>[0]
|
||||||
|
|
||||||
|
const LOOKUP_EXPORT_CHUNK_SIZE = 1000
|
||||||
|
|
||||||
|
const chunk = <T>(values: T[], size: number): T[][] => {
|
||||||
|
const chunks: T[][] = []
|
||||||
|
for (let index = 0; index < values.length; index += size) {
|
||||||
|
chunks.push(values.slice(index, index + size))
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOccurrenceKeys = (occurrenceKeys: DwcOccurrenceKey[]): DwcOccurrenceKey[] => {
|
||||||
|
return [...occurrenceKeys].sort((a, b) => a.lid - b.lid || a.speciesId - b.speciesId)
|
||||||
|
}
|
||||||
|
|
||||||
export const DWC_DP_EVENT_HEADERS = [
|
export const DWC_DP_EVENT_HEADERS = [
|
||||||
'eventID',
|
'eventID',
|
||||||
'parentEventID',
|
'parentEventID',
|
||||||
@@ -334,7 +348,7 @@ const FIELD_DESCRIPTIONS: Record<string, string> = {
|
|||||||
taxonID: 'Stable NOW taxon identifier; this joins to dwc-a-taxa/taxon.csv in the full export.',
|
taxonID: 'Stable NOW taxon identifier; this joins to dwc-a-taxa/taxon.csv in the full export.',
|
||||||
scientificName: 'Scientific name assembled from curated NOW taxonomic fields.',
|
scientificName: 'Scientific name assembled from curated NOW taxonomic fields.',
|
||||||
scientificNameAuthorship: 'Scientific name authorship where curated.',
|
scientificNameAuthorship: 'Scientific name authorship where curated.',
|
||||||
taxonRank: 'Taxonomic rank when available; currently reserved for future enrichment.',
|
taxonRank: 'Taxonomic rank derived from curated NOW taxonomic fields when available.',
|
||||||
identificationVerificationStatus: 'Curated identification status or qualifier.',
|
identificationVerificationStatus: 'Curated identification status or qualifier.',
|
||||||
assertionID: 'Stable assertion identifier derived from the source database field and owning event or occurrence.',
|
assertionID: 'Stable assertion identifier derived from the source database field and owning event or occurrence.',
|
||||||
verbatimAssertionType:
|
verbatimAssertionType:
|
||||||
@@ -913,15 +927,26 @@ const fetchOccurrencesForDwcDataPackageExport = async (
|
|||||||
): Promise<OccurrenceForDwcDpExport[]> => {
|
): Promise<OccurrenceForDwcDpExport[]> => {
|
||||||
if (occurrenceKeys && occurrenceKeys.length === 0) return []
|
if (occurrenceKeys && occurrenceKeys.length === 0) return []
|
||||||
const { nowDb } = await import('../utils/db')
|
const { nowDb } = await import('../utils/db')
|
||||||
const occurrences = await nowDb.now_ls.findMany({
|
|
||||||
where: occurrenceKeys
|
if (occurrenceKeys) {
|
||||||
? {
|
const occurrences: OccurrenceForDwcDpExport[] = []
|
||||||
OR: occurrenceKeys.map(key => ({
|
for (const keys of chunk(sortOccurrenceKeys(occurrenceKeys), LOOKUP_EXPORT_CHUNK_SIZE)) {
|
||||||
|
const chunkOccurrences = await nowDb.now_ls.findMany({
|
||||||
|
where: {
|
||||||
|
OR: keys.map(key => ({
|
||||||
lid: key.lid,
|
lid: key.lid,
|
||||||
species_id: key.speciesId,
|
species_id: key.speciesId,
|
||||||
})),
|
})),
|
||||||
}
|
},
|
||||||
: undefined,
|
orderBy: [{ lid: 'asc' }, { species_id: 'asc' }],
|
||||||
|
select: occurrenceSelect,
|
||||||
|
})
|
||||||
|
occurrences.push(...(chunkOccurrences as unknown as OccurrenceForDwcDpExport[]))
|
||||||
|
}
|
||||||
|
return occurrences
|
||||||
|
}
|
||||||
|
|
||||||
|
const occurrences = await nowDb.now_ls.findMany({
|
||||||
orderBy: [{ lid: 'asc' }, { species_id: 'asc' }],
|
orderBy: [{ lid: 'asc' }, { species_id: 'asc' }],
|
||||||
select: occurrenceSelect,
|
select: occurrenceSelect,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from '@jest/globals'
|
||||||
|
import { parseNumericIds } from '../routes/utils/exportFilters'
|
||||||
|
|
||||||
|
describe('parseNumericIds', () => {
|
||||||
|
it('accepts integer ids as numbers or strings', () => {
|
||||||
|
expect(parseNumericIds([12, '34'])).toEqual([12, 34])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects partially numeric strings', () => {
|
||||||
|
expect(() => parseNumericIds(['12abc'])).toThrow('ids must contain only integers.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects decimals', () => {
|
||||||
|
expect(() => parseNumericIds([12.3])).toThrow('ids must contain only integers.')
|
||||||
|
expect(() => parseNumericIds(['12.3'])).toThrow('ids must contain only integers.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-array id payloads', () => {
|
||||||
|
expect(() => parseNumericIds('12')).toThrow('ids must be an array.')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -39,6 +39,7 @@ export const OccurrenceDwcExportMenuItem = ({ handleClose }: { handleClose: () =
|
|||||||
progressUrl: `${BACKEND_URL}/occurrence/export/dwc-archive/progress/${exportId}`,
|
progressUrl: `${BACKEND_URL}/occurrence/export/dwc-archive/progress/${exportId}`,
|
||||||
filename,
|
filename,
|
||||||
fetchOptions: filteredFetchOptions,
|
fetchOptions: filteredFetchOptions,
|
||||||
|
progressFetchOptions: fetchOptions,
|
||||||
notify,
|
notify,
|
||||||
setNotificationMessage,
|
setNotificationMessage,
|
||||||
startMessage: 'Generating DwC-A occurrence ZIP export...',
|
startMessage: 'Generating DwC-A occurrence ZIP export...',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type DownloadExportParams = {
|
|||||||
url: string
|
url: string
|
||||||
filename: string
|
filename: string
|
||||||
fetchOptions?: RequestInit
|
fetchOptions?: RequestInit
|
||||||
|
progressFetchOptions?: RequestInit
|
||||||
notify: Notify
|
notify: Notify
|
||||||
setNotificationMessage: (message: string) => void
|
setNotificationMessage: (message: string) => void
|
||||||
startMessage: string
|
startMessage: string
|
||||||
@@ -30,6 +31,7 @@ export const downloadExportFileWithProgress = async ({
|
|||||||
url,
|
url,
|
||||||
filename,
|
filename,
|
||||||
fetchOptions = {},
|
fetchOptions = {},
|
||||||
|
progressFetchOptions,
|
||||||
notify,
|
notify,
|
||||||
setNotificationMessage,
|
setNotificationMessage,
|
||||||
startMessage,
|
startMessage,
|
||||||
@@ -53,7 +55,7 @@ export const downloadExportFileWithProgress = async ({
|
|||||||
if (!progressUrl) return false
|
if (!progressUrl) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(progressUrl, fetchOptions)
|
const response = await fetch(progressUrl, progressFetchOptions ?? fetchOptions)
|
||||||
if (!response.ok) return false
|
if (!response.ok) return false
|
||||||
|
|
||||||
const progress = (await response.json()) as ExportProgress
|
const progress = (await response.json()) as ExportProgress
|
||||||
|
|||||||
Reference in New Issue
Block a user