Address Darwin Core export review comments

This commit is contained in:
karilint
2026-06-15 14:09:24 +03:00
parent 53ac4192ff
commit bbc11e44b9
9 changed files with 79 additions and 35 deletions
+2 -11
View File
@@ -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.' })
} }
}) })
+1 -1
View File
@@ -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) => {
+2 -11
View File
@@ -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.' })
} }
}) })
+11
View File
@@ -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
})
}
+3 -1
View File
@@ -268,11 +268,13 @@ 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) {
for (const row of page) {
if (typeof row.lid_now_loc !== 'number' || typeof row.species_id_com_species !== 'number') continue if (typeof row.lid_now_loc !== 'number' || typeof row.species_id_com_species !== 'number') continue
const key = { lid: row.lid_now_loc, speciesId: row.species_id_com_species } const key = { lid: row.lid_now_loc, speciesId: row.species_id_com_species }
keysById.set(`${key.lid}:${key.speciesId}`, key) keysById.set(`${key.lid}:${key.speciesId}`, key)
} }
}
return { occurrenceKeys: [...keysById.values()] } return { occurrenceKeys: [...keysById.values()] }
} }
+31 -6
View File
@@ -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,
})), })),
},
orderBy: [{ lid: 'asc' }, { species_id: 'asc' }],
select: occurrenceSelect,
})
occurrences.push(...(chunkOccurrences as unknown as OccurrenceForDwcDpExport[]))
} }
: undefined, 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...',
+3 -1
View File
@@ -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