Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
00a28a2
Admin-only DwC-A export for species
karilint Apr 23, 2026
8f83461
Add verbatimMeasurementType and methods
karilint Apr 23, 2026
00277b3
Update DwC taxon mapping
karilint Apr 23, 2026
ce934d0
Fix meta.xml fieldsEnclosedBy quoting
karilint Apr 23, 2026
6e8ebc9
Expand measurement export and drop remarks
karilint Apr 23, 2026
2632ff0
Adjust DwC scientificName and drop taxonConceptID
karilint Apr 23, 2026
4124074
Prefix taxonID and refine taxonRank heuristics
karilint Apr 24, 2026
464a4d1
DwC-A: parentMeasurementID + crown type rows
karilint Apr 24, 2026
58091d4
DwC-A: revise taxonRank heuristics
karilint Apr 27, 2026
41ce6e7
Add admin-only DwC-A locality export
karilint Apr 27, 2026
a8b0178
Fix locality export Prisma client usage
karilint Apr 27, 2026
0df9384
DwC-A localities: add continent/higherGeography/elevation
karilint Apr 27, 2026
7557912
DwC-A localities: expand MeasurementOrFact + parents
karilint Apr 27, 2026
9459dc8
DwC-A localities: only concat requested fields
karilint Apr 27, 2026
101fac6
DwC-A localities: export climate/ecometrics/archaeology facts
karilint Apr 27, 2026
30040ea
DwC-A localities: include remaining now_loc fields
karilint Apr 28, 2026
ec4edd4
Adjust locality DwC export fields
karilint Apr 28, 2026
51fca00
Add DwC occurrence export
karilint Apr 28, 2026
e90e24d
Stream DwC occurrence export
karilint Apr 28, 2026
3a5e68f
Show DwC occurrence export progress
karilint Apr 29, 2026
190859c
Tolerate invalid locality reference dates
karilint Apr 29, 2026
172aa16
Tolerate invalid occurrence reference dates
karilint Apr 29, 2026
647773d
Harden reference date reads
karilint Apr 29, 2026
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
77 changes: 77 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"express-rate-limit": "^7.5.0",
"fast-csv": "^5.0.2",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"mariadb": "^3.3.0",
"md5": "^2.3.0",
"nodemailer": "^6.9.14",
Expand Down
65 changes: 65 additions & 0 deletions backend/src/api-tests/locality/dwcArchiveExportLocalities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
import request from 'supertest'
import JSZip from 'jszip'
import type { Response } from 'superagent'
import app from '../../app'
import { pool } from '../../utils/db'
import { noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'

type ResponseStream = {
on: (event: 'data', handler: (chunk: Buffer) => void) => void
} & {
on: (event: 'end', handler: () => void) => void
}

const parseBinary = (res: Response, callback: (err: Error | null, body: Buffer) => void) => {
const data: Buffer[] = []
const stream = res as unknown as ResponseStream
stream.on('data', chunk => data.push(chunk))
stream.on('end', () => {
callback(null, Buffer.concat(data))
})
}

describe('DwC-A locality export (admin-only)', () => {
beforeAll(async () => {
await resetDatabase()
}, resetDatabaseTimeout)

afterAll(async () => {
await pool.end()
})

it('returns a ZIP archive for admins', async () => {
const loginResult = await send<{ token: string }>('user/login', 'POST', { username: 'testSu', password: 'test' })
expect(loginResult.status).toEqual(200)

const result = await request(app)
.get('/locality/export/dwc-archive')
.set('authorization', `bearer ${loginResult.body.token}`)
.buffer(true)
.parse(parseBinary)

expect(result.status).toEqual(200)
expect(result.headers['content-type']).toMatch(/application\/zip/i)
expect(result.headers['content-disposition']).toMatch(/attachment;\s*filename="now_dwc_localities_test_export_/i)

const zip = await JSZip.loadAsync(result.body as unknown as Buffer)
expect(zip.file('location.csv')).toBeTruthy()
expect(zip.file('geologicalcontext.csv')).toBeTruthy()
expect(zip.file('measurementorfact.csv')).toBeTruthy()
expect(zip.file('meta.xml')).toBeTruthy()
expect(zip.file('eml.xml')).toBeTruthy()

const measurementCsv = await zip.file('measurementorfact.csv')!.async('string')
expect(measurementCsv).toContain('"measurementID"')
expect(measurementCsv).toContain('"parentMeasurementID"')
expect(measurementCsv).toContain('"verbatimMeasurementType"')
})

it('rejects non-admin requests', async () => {
const result = await request(app).get('/locality/export/dwc-archive')
expect(result.status).toEqual(403)
expect(result.body).toEqual(noPermError)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
import request from 'supertest'
import JSZip from 'jszip'
import type { Response } from 'superagent'
import app from '../../app'
import { pool } from '../../utils/db'
import { noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'

type ResponseStream = {
on: (event: 'data', handler: (chunk: Buffer) => void) => void
} & {
on: (event: 'end', handler: () => void) => void
}

const parseBinary = (res: Response, callback: (err: Error | null, body: Buffer) => void) => {
const data: Buffer[] = []
const stream = res as unknown as ResponseStream
stream.on('data', chunk => data.push(chunk))
stream.on('end', () => {
callback(null, Buffer.concat(data))
})
}

describe('DwC-A occurrence export (admin-only)', () => {
beforeAll(async () => {
await resetDatabase()
}, resetDatabaseTimeout)

afterAll(async () => {
await pool.end()
})

it('returns a ZIP archive for admins', async () => {
const loginResult = await send<{ token: string }>('user/login', 'POST', { username: 'testSu', password: 'test' })
expect(loginResult.status).toEqual(200)

const result = await request(app)
.get('/occurrence/export/dwc-archive')
.set('authorization', `bearer ${loginResult.body.token}`)
.buffer(true)
.parse(parseBinary)

expect(result.status).toEqual(200)
expect(result.headers['content-type']).toMatch(/application\/zip/i)
expect(result.headers['content-disposition']).toMatch(/attachment;\s*filename="now_dwc_occurrences_test_export_/i)

const zip = await JSZip.loadAsync(result.body as unknown as Buffer)
expect(zip.file('location.csv')).toBeTruthy()
expect(zip.file('geologicalcontext.csv')).toBeTruthy()
expect(zip.file('taxon.csv')).toBeTruthy()
expect(zip.file('occurrence.csv')).toBeTruthy()
expect(zip.file('measurementorfact.csv')).toBeTruthy()
expect(zip.file('meta.xml')).toBeTruthy()
expect(zip.file('eml.xml')).toBeTruthy()

const occurrenceCsv = await zip.file('occurrence.csv')!.async('string')
expect(occurrenceCsv).toContain('"occurrenceID"')
expect(occurrenceCsv).toContain('"locationID"')
expect(occurrenceCsv).toContain('"taxonID"')

const measurementCsv = await zip.file('measurementorfact.csv')!.async('string')
expect(measurementCsv).toContain('"verbatimMeasurementType"')
})

it('rejects non-admin requests', async () => {
const result = await request(app).get('/occurrence/export/dwc-archive')
expect(result.status).toEqual(403)
expect(result.body).toEqual(noPermError)
})
})
74 changes: 74 additions & 0 deletions backend/src/api-tests/species/dwcArchiveExport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
import request from 'supertest'
import JSZip from 'jszip'
import type { Response } from 'superagent'
import app from '../../app'
import { pool } from '../../utils/db'
import { noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'

type ResponseStream = {
on: (event: 'data', handler: (chunk: Buffer) => void) => void
} & {
on: (event: 'end', handler: () => void) => void
}

const parseBinary = (res: Response, callback: (err: Error | null, body: Buffer) => void) => {
const data: Buffer[] = []
const stream = res as unknown as ResponseStream
stream.on('data', chunk => data.push(chunk))
stream.on('end', () => {
callback(null, Buffer.concat(data))
})
}

describe('DwC-A species export (admin-only)', () => {
beforeAll(async () => {
await resetDatabase()
}, resetDatabaseTimeout)

afterAll(async () => {
await pool.end()
})

it('returns a ZIP archive for admins', async () => {
const loginResult = await send<{ token: string }>('user/login', 'POST', { username: 'testSu', password: 'test' })
expect(loginResult.status).toEqual(200)

const result = await request(app)
.get('/species/export/dwc-archive')
.set('authorization', `bearer ${loginResult.body.token}`)
.buffer(true)
.parse(parseBinary)

expect(result.status).toEqual(200)
expect(result.headers['content-type']).toMatch(/application\/zip/i)
expect(result.headers['content-disposition']).toMatch(/attachment;\s*filename="now_dwc_test_export_/i)

const zip = await JSZip.loadAsync(result.body as unknown as Buffer)
expect(zip.file('taxon.csv')).toBeTruthy()
expect(zip.file('measurementorfact.csv')).toBeTruthy()
expect(zip.file('meta.xml')).toBeTruthy()
expect(zip.file('eml.xml')).toBeTruthy()

const taxonCsv = await zip.file('taxon.csv')!.async('string')
expect(taxonCsv).toContain('"taxonID"')
expect(taxonCsv).toContain('"nomenclaturalCode"')
expect(taxonCsv).toContain('"genericName"')

const measurementCsv = await zip.file('measurementorfact.csv')!.async('string')
expect(measurementCsv).toContain('"measurementID"')
expect(measurementCsv).toContain('"parentMeasurementID"')
expect(measurementCsv).toContain('"verbatimMeasurementType"')
expect(measurementCsv).not.toContain('"measurementRemarks"')

const metaXml = await zip.file('meta.xml')!.async('string')
expect(metaXml).toContain('<core')
expect(metaXml).toContain('<extension')
})

it('rejects non-admin requests', async () => {
const result = await request(app).get('/species/export/dwc-archive')
expect(result.status).toEqual(403)
expect(result.body).toEqual(noPermError)
})
})
12 changes: 12 additions & 0 deletions backend/src/routes/locality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { fixBigInt } from '../utils/common'
import { EditDataType, EditMetaData, LocalityDetailsType, Role } from '../../../frontend/src/shared/types'
import { AccessError, requireOneOf } from '../middlewares/authorizer'
import { deleteLocality, writeLocality } from '../services/write/locality'
import { buildDwcLocalityArchiveZipBuffer } from '../services/dwcArchiveExportLocalities'
import { currentDateAsString } from '../../../frontend/src/shared/currentDateAsString'

const router = Router()

Expand All @@ -18,6 +20,16 @@ router.get('/all', async (req, res) => {
return res.status(200).send(fixBigInt(localities))
})

router.get('/export/dwc-archive', requireOneOf([Role.Admin]), async (_req, res) => {
const zipBuffer = await buildDwcLocalityArchiveZipBuffer()
res.setHeader('Content-Type', 'application/zip')
res.setHeader(
'Content-Disposition',
`attachment; filename="now_dwc_localities_test_export_${currentDateAsString()}.zip"`
)
return res.status(200).send(zipBuffer)
})

router.get('/:id', async (req, res) => {
const id = parseInt(req.params.id)
const locality = await getLocalityDetails(id, req.user)
Expand Down
Loading
Loading