From 08cfb1bd7bb841bc24c173fb5f60a5b05028a74d Mon Sep 17 00:00:00 2001 From: karilint Date: Tue, 14 Apr 2026 15:11:05 +0300 Subject: [PATCH] Improve species merge UX and docs --- AGENTS.md | 7 + backend/src/api-tests/species/merge.test.ts | 325 +++++++ backend/src/app.ts | 2 + backend/src/routes/speciesMerge.ts | 56 ++ backend/src/services/speciesMerge.ts | 610 +++++++++++++ backend/src/utils/db.ts | 12 +- docker-compose.dev.yml | 11 + documentation/codex_planning_prompt.md | 7 + .../coding_prompt_template_fullstack.md | 3 + documentation/devops/database.md | 8 + documentation/devops/setup.md | 3 +- documentation/functionality/species_merge.md | 68 ++ documentation/user_rights.md | 1 + .../src/components/DetailView/components.tsx | 5 +- frontend/src/components/NavBar.tsx | 1 + frontend/src/pages/admin/SpeciesMergePage.tsx | 822 ++++++++++++++++++ frontend/src/redux/speciesReducer.ts | 30 +- frontend/src/router/index.tsx | 7 + frontend/src/shared/types/data.ts | 151 ++++ .../src/tests/pages/SpeciesMergePage.test.tsx | 227 +++++ package.json | 2 + 21 files changed, 2354 insertions(+), 4 deletions(-) create mode 100644 backend/src/api-tests/species/merge.test.ts create mode 100644 backend/src/routes/speciesMerge.ts create mode 100644 backend/src/services/speciesMerge.ts create mode 100644 documentation/functionality/species_merge.md create mode 100644 frontend/src/pages/admin/SpeciesMergePage.tsx create mode 100644 frontend/src/tests/pages/SpeciesMergePage.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 8503d34e..a2174532 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,3 +128,10 @@ When tradeoffs appear, prefer: - Be concise and practical. - Mention files changed, validation performed, and any unresolved risk. - If a task was only partially verifiable locally, say so clearly. + +## Database Safety (Mandatory) + +- Never run database reset or restore commands against the **development** database unless the user explicitly asks for it. +- Treat `/test/reset-test-database` and any SQL in `test_data/sqlfiles/` as **test-only** fixtures. Do not point dev services at those databases. +- Before running any DB reset/restore, confirm which **DB host, port, and database names** the backend is using and whether they are isolated from dev data. +- If test and dev share the same database container or port, stop and ask the user to separate them before proceeding. diff --git a/backend/src/api-tests/species/merge.test.ts b/backend/src/api-tests/species/merge.test.ts new file mode 100644 index 00000000..f5138589 --- /dev/null +++ b/backend/src/api-tests/species/merge.test.ts @@ -0,0 +1,325 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals' +import { pool } from '../../utils/db' +import { login, noPermError, resetDatabaseTimeout, send, setToken } from '../utils' +import { cloneSpeciesData } from './data' +import { OCCURRENCE_MERGE_FIELDS } from '../../services/speciesMerge' +import { SpeciesDetailsType } from '../../../../frontend/src/shared/types' + +type MergeResponse = { + message: string + suid: number + coordinator: string + editor: string + date: string + comment: string +} + +const buildOccurrenceChoices = ( + lid: number, + overrides?: Partial> +) => { + const fieldChoice = OCCURRENCE_MERGE_FIELDS.reduce( + (acc, field) => { + acc[field] = 'accepted' + return acc + }, + {} as Record<(typeof OCCURRENCE_MERGE_FIELDS)[number], 'accepted' | 'obsolete'> + ) + + if (overrides) { + for (const [field, value] of Object.entries(overrides)) { + fieldChoice[field as (typeof OCCURRENCE_MERGE_FIELDS)[number]] = value + } + } + + return { lid, fieldChoice } +} + +describe('Species merge endpoint', () => { + beforeAll(async () => { + const resetResult = await send('test/reset-test-database', 'GET') + expect(resetResult.status).toEqual(200) + const createUsersResult = await send('test/create-test-users', 'GET') + expect(createUsersResult.status).toEqual(200) + const loginResult = await send<{ token: string }>('user/login', 'POST', { username: 'testSu', password: 'test' }) + expect(loginResult.status).toEqual(200) + setToken(loginResult.body.token) + }, resetDatabaseTimeout) + + beforeEach(async () => { + const loginResult = await send<{ token: string }>('user/login', 'POST', { username: 'testSu', password: 'test' }) + expect(loginResult.status).toEqual(200) + setToken(loginResult.body.token) + }) + + afterAll(async () => { + await pool.end() + }) + + it('merges species, migrates synonyms, and deletes obsolete species', async () => { + const references = cloneSpeciesData().references + + const acceptedPayload = { + ...cloneSpeciesData(), + genus_name: 'MergeAcceptedGenus', + species_name: 'mergeAccepted', + unique_identifier: 'merge-accepted', + diet1: 'a', + now_ls: [ + { + rowState: 'new', + lid: 24750, + mni: 1, + source_name: 'Accepted Source', + mesowear: 'bil', + }, + ], + com_taxa_synonym: [], + comment: 'create accepted', + references, + } + + const obsoletePayload = { + ...cloneSpeciesData(), + genus_name: 'MergeObsoleteGenus', + species_name: 'mergeObsolete', + unique_identifier: 'merge-obsolete', + diet1: 'b', + now_ls: [ + { + rowState: 'new', + lid: 24750, + mni: 2, + source_name: 'Obsolete Source', + mesowear: 'mix', + }, + ], + com_taxa_synonym: [ + { + rowState: 'new', + syn_genus_name: 'LegacyGenus', + syn_species_name: 'legatus', + syn_comment: 'legacy synonym', + }, + ], + comment: 'create obsolete', + references, + } + + const acceptedCreate = await send<{ species_id: number }>('species', 'PUT', { species: acceptedPayload }) + const obsoleteCreate = await send<{ species_id: number }>('species', 'PUT', { species: obsoletePayload }) + + if (acceptedCreate.status !== 200) { + throw new Error(`Accepted species create failed: ${JSON.stringify(acceptedCreate.body)}`) + } + if (obsoleteCreate.status !== 200) { + throw new Error(`Obsolete species create failed: ${JSON.stringify(obsoleteCreate.body)}`) + } + + const acceptedId = acceptedCreate.body.species_id + const obsoleteId = obsoleteCreate.body.species_id + + const mergeResult = await send('admin/species-merge', 'POST', { + obsoleteSpeciesId: obsoleteId, + acceptedSpeciesId: acceptedId, + selectedSpeciesFieldValues: { + diet1: 'b', + }, + occurrenceFieldChoices: [ + buildOccurrenceChoices(24750, { + mni: 'obsolete', + source_name: 'obsolete', + mesowear: 'obsolete', + }), + ], + addObsoleteAsSynonym: true, + synonymComment: 'merged synonym', + addSourceNameToOccurrences: false, + comment: 'merge species', + references, + }) + + if (mergeResult.status !== 200) { + throw new Error(`Merge failed: ${JSON.stringify(mergeResult.body)}`) + } + expect(mergeResult.body.message).toEqual('SPECIES MERGED SUCCESSFULLY') + expect(typeof mergeResult.body.suid).toEqual('number') + + const { body: mergedSpecies, status: mergedStatus } = await send(`species/${acceptedId}`, 'GET') + expect(mergedStatus).toEqual(200) + expect(mergedSpecies.diet1).toEqual('b') + + const mergedOccurrence = mergedSpecies.now_ls.find(ls => ls.lid === 24750) + expect(mergedOccurrence).toBeDefined() + expect(mergedOccurrence?.mni).toEqual(2) + expect(mergedOccurrence?.source_name).toEqual('Obsolete Source') + expect(mergedOccurrence?.mesowear).toEqual('mix') + + const synonymNames = mergedSpecies.com_taxa_synonym.map(syn => `${syn.syn_genus_name} ${syn.syn_species_name}`) + expect(synonymNames).toContain('LegacyGenus legatus') + expect(synonymNames).toContain('MergeObsoleteGenus mergeObsolete') + + const obsoleteFetch = await send(`species/${obsoleteId}`, 'GET') + expect(obsoleteFetch.status).toEqual(404) + }) + + it('requires references', async () => { + const references = cloneSpeciesData().references + const acceptedCreate = await send<{ species_id: number }>('species', 'PUT', { + species: { + ...cloneSpeciesData(), + genus_name: 'RefAcceptGenus', + species_name: 'refAccept', + unique_identifier: 'ref-accept', + now_ls: [], + comment: 'create accepted', + references, + }, + }) + + const obsoleteCreate = await send<{ species_id: number }>('species', 'PUT', { + species: { + ...cloneSpeciesData(), + genus_name: 'RefObsoleteGenus', + species_name: 'refObsolete', + unique_identifier: 'ref-obsolete', + now_ls: [], + comment: 'create obsolete', + references, + }, + }) + + const mergeResult = await send('admin/species-merge', 'POST', { + obsoleteSpeciesId: obsoleteCreate.body.species_id, + acceptedSpeciesId: acceptedCreate.body.species_id, + selectedSpeciesFieldValues: {}, + occurrenceFieldChoices: [], + addObsoleteAsSynonym: false, + addSourceNameToOccurrences: false, + comment: 'merge species', + references: [], + }) + + if (mergeResult.status !== 400) { + throw new Error( + `Expected 400 for missing references, got ${mergeResult.status}: ${JSON.stringify(mergeResult.body)}` + ) + } + }) + + it('summary includes non-taxonomic fields and omits taxonomic fields', async () => { + const references = cloneSpeciesData().references + + const acceptedCreate = await send<{ species_id: number }>('species', 'PUT', { + species: { + ...cloneSpeciesData(), + genus_name: 'SummaryAcceptGenus', + species_name: 'summaryAccept', + unique_identifier: 'summary-accept', + diet_description: 'accept diet description', + now_ls: [], + comment: 'create accepted', + references, + }, + }) + + const obsoleteCreate = await send<{ species_id: number }>('species', 'PUT', { + species: { + ...cloneSpeciesData(), + genus_name: 'SummaryObsoleteGenus', + species_name: 'summaryObsolete', + unique_identifier: 'summary-obsolete', + diet_description: 'obsolete diet description', + now_ls: [], + comment: 'create obsolete', + references, + }, + }) + + const summaryResult = await send<{ + speciesFieldChoices: Array<{ field: string }> + }>( + `admin/species-merge/summary?obsoleteId=${obsoleteCreate.body.species_id}&acceptedId=${acceptedCreate.body.species_id}`, + 'GET' + ) + + if (summaryResult.status !== 200) { + throw new Error(`Summary failed: ${JSON.stringify(summaryResult.body)}`) + } + + const fields = summaryResult.body.speciesFieldChoices.map(choice => choice.field) + expect(fields).toContain('diet_description') + expect(fields).not.toContain('order_name') + }) + + it('merges numeric fields according to selection', async () => { + const references = cloneSpeciesData().references + + const acceptedCreate = await send<{ species_id: number }>('species', 'PUT', { + species: { + ...cloneSpeciesData(), + genus_name: 'BoolAcceptGenus', + species_name: 'boolAccept', + unique_identifier: 'bool-accept', + body_mass: 10, + now_ls: [], + comment: 'create accepted', + references, + }, + }) + + const obsoleteCreate = await send<{ species_id: number }>('species', 'PUT', { + species: { + ...cloneSpeciesData(), + genus_name: 'BoolObsoleteGenus', + species_name: 'boolObsolete', + unique_identifier: 'bool-obsolete', + body_mass: 99, + now_ls: [], + comment: 'create obsolete', + references, + }, + }) + + const acceptedId = acceptedCreate.body.species_id + const obsoleteId = obsoleteCreate.body.species_id + + const mergeResult = await send('admin/species-merge', 'POST', { + obsoleteSpeciesId: obsoleteId, + acceptedSpeciesId: acceptedId, + selectedSpeciesFieldValues: { + body_mass: 99, + }, + occurrenceFieldChoices: [], + addObsoleteAsSynonym: false, + addSourceNameToOccurrences: false, + comment: 'merge boolean/numeric', + references, + }) + + if (mergeResult.status !== 200) { + throw new Error(`Merge failed: ${JSON.stringify(mergeResult.body)}`) + } + + const { body: mergedSpecies, status: mergedStatus } = await send(`species/${acceptedId}`, 'GET') + expect(mergedStatus).toEqual(200) + expect(mergedSpecies.body_mass).toEqual(99) + }) + + it('rejects non-admin users', async () => { + await login('testEr') + const result = await send('admin/species-merge', 'POST', { + obsoleteSpeciesId: 1, + acceptedSpeciesId: 2, + selectedSpeciesFieldValues: {}, + occurrenceFieldChoices: [], + addObsoleteAsSynonym: false, + addSourceNameToOccurrences: false, + comment: 'merge species', + references: [], + }) + + expect(result.status).toEqual(403) + expect(result.body).toEqual(noPermError) + }) +}) diff --git a/backend/src/app.ts b/backend/src/app.ts index e5acb263..a20c438f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -31,6 +31,7 @@ import { Role } from './../../frontend/src/shared/types' import { blockWriteRequests } from './middlewares/misc' import testRouter from './routes/test' import occurrenceRouter from './routes/occurrence' +import speciesMergeRouter from './routes/speciesMerge' const app = express() @@ -57,6 +58,7 @@ app.use('/occurrence', occurrenceRouter) app.use('/locality', localityRouter) app.use('/locality-species', localitySpeciesRouter) app.use('/species', speciesRouter) +app.use('/admin/species-merge', requireOneOf([Role.Admin]), speciesMergeRouter) app.use('/statistics', statisticsRouter) app.use('/reference', referenceRouter) app.use('/time-unit', timeUnitRouter) diff --git a/backend/src/routes/speciesMerge.ts b/backend/src/routes/speciesMerge.ts new file mode 100644 index 00000000..47e24aea --- /dev/null +++ b/backend/src/routes/speciesMerge.ts @@ -0,0 +1,56 @@ +import { Router } from 'express' +import { getSpeciesMergeSummary, mergeSpecies, SpeciesMergeRequest } from '../services/speciesMerge' + +const router = Router() + +router.get('/summary', async (req, res) => { + const obsoleteIdRaw = req.query.obsoleteId + const acceptedIdRaw = req.query.acceptedId + + const obsoleteId = typeof obsoleteIdRaw === 'string' ? parseInt(obsoleteIdRaw, 10) : NaN + const acceptedId = typeof acceptedIdRaw === 'string' ? parseInt(acceptedIdRaw, 10) : NaN + + if (!Number.isFinite(obsoleteId) || !Number.isFinite(acceptedId)) { + return res.status(400).json({ message: 'obsoleteId and acceptedId must be valid integers' }) + } + + if (obsoleteId === acceptedId) { + return res.status(400).json({ message: 'obsoleteId and acceptedId must be different' }) + } + + const summary = await getSpeciesMergeSummary(obsoleteId, acceptedId) + if (!summary) { + return res.status(404).json({ message: 'Species not found' }) + } + + return res.status(200).send(summary) +}) + +router.post('/', async (req, res) => { + const payload = req.body as SpeciesMergeRequest + + if (!payload || typeof payload !== 'object') { + return res.status(400).json({ message: 'Invalid payload' }) + } + + if ( + typeof payload.obsoleteSpeciesId !== 'number' || + typeof payload.acceptedSpeciesId !== 'number' || + payload.obsoleteSpeciesId === payload.acceptedSpeciesId + ) { + return res.status(400).json({ message: 'obsoleteSpeciesId and acceptedSpeciesId must be different integers' }) + } + + try { + if (!req.user) { + return res.status(401).json({ message: 'User not authenticated' }) + } + const result = await mergeSpecies(payload, req.user) + return res.status(200).json(result) + } catch (error) { + const message = error instanceof Error ? error.message : 'Merge failed' + return res.status(400).json({ message }) + } +}) + +export default router diff --git a/backend/src/services/speciesMerge.ts b/backend/src/services/speciesMerge.ts new file mode 100644 index 00000000..ef587b28 --- /dev/null +++ b/backend/src/services/speciesMerge.ts @@ -0,0 +1,610 @@ +import { Reference, User } from '../../../frontend/src/shared/types' +import { referenceValidator } from '../../../frontend/src/shared/validators/validator' +import { getReferenceDetails } from './reference' +import { deleteSpecies } from './write/species' +import { fixBigInt } from '../utils/common' +import { getFieldsOfTables, nowDb } from '../utils/db' +import { NOW_DB_NAME } from '../utils/config' +import { WriteHandler } from './write/writeOperations/writeHandler' + +type SpeciesSummary = { + species_id: number + order_name: string | null + family_name: string | null + genus_name: string | null + species_name: string | null + subclass_or_superorder_name: string | null + suborder_or_superfamily_name: string | null + subfamily_name: string | null + unique_identifier: string | null + localities: number + sv_length: string | null + body_mass: number | null + sd_size: string | null + sd_display: string | null + tshm: string | null + tht: string | null + crowntype: string | null + diet1: string | null + diet2: string | null + diet3: string | null + locomo1: string | null + locomo2: string | null + locomo3: string | null + sp_author?: string | null +} + +type SpeciesFieldChoice = { + field: string + obsoleteValue: string | number | boolean | null + acceptedValue: string | number | boolean | null + defaultChoice: 'accepted' | 'obsolete' +} + +export const OCCURRENCE_MERGE_FIELDS = [ + 'nis', + 'pct', + 'quad', + 'mni', + 'qua', + 'id_status', + 'orig_entry', + 'source_name', + 'body_mass', + 'mesowear', + 'mw_or_high', + 'mw_or_low', + 'mw_cs_sharp', + 'mw_cs_round', + 'mw_cs_blunt', + 'mw_scale_min', + 'mw_scale_max', + 'mw_value', + 'microwear', + 'dc13_mean', + 'dc13_n', + 'dc13_max', + 'dc13_min', + 'dc13_stdev', + 'do18_mean', + 'do18_n', + 'do18_max', + 'do18_min', + 'do18_stdev', +] as const + +export type OccurrenceMergeField = (typeof OCCURRENCE_MERGE_FIELDS)[number] + +type OccurrenceConflict = { + lid: number + localityName: string | null + country: string | null + obsolete: Record + accepted: Record + defaultChoice: Record +} + +type SpeciesMergeSummaryResponse = { + obsolete: SpeciesSummary + accepted: SpeciesSummary + speciesFieldChoices: SpeciesFieldChoice[] + occurrenceConflicts: OccurrenceConflict[] +} + +export type SpeciesMergeRequest = { + obsoleteSpeciesId: number + acceptedSpeciesId: number + selectedSpeciesFieldValues: Record + occurrenceFieldChoices: Array<{ + lid: number + fieldChoice: Record + }> + addObsoleteAsSynonym: boolean + synonymComment?: string + addSourceNameToOccurrences: boolean + comment: string + references: Reference[] +} + +export type SpeciesMergeResponse = { + message: 'SPECIES MERGED SUCCESSFULLY' + suid: number + coordinator: string + editor: string + date: string + comment: string +} + +const SPECIES_SUMMARY_FIELDS = { + species_id: true, + order_name: true, + family_name: true, + genus_name: true, + species_name: true, + subclass_or_superorder_name: true, + suborder_or_superfamily_name: true, + subfamily_name: true, + unique_identifier: true, + sv_length: true, + body_mass: true, + sd_size: true, + sd_display: true, + tshm: true, + tht: true, + crowntype: true, + diet1: true, + diet2: true, + diet3: true, + locomo1: true, + locomo2: true, + locomo3: true, + sp_author: true, +} as const + +const SPECIES_COPY_FIELDS = [ + 'taxonomic_status', + 'common_name', + 'sp_author', + 'strain', + 'gene', + 'taxon_status', + 'diet1', + 'diet2', + 'diet3', + 'diet_description', + 'rel_fib', + 'selectivity', + 'digestion', + 'feedinghab1', + 'feedinghab2', + 'shelterhab1', + 'shelterhab2', + 'locomo1', + 'locomo2', + 'locomo3', + 'hunt_forage', + 'body_mass', + 'brain_mass', + 'sv_length', + 'activity', + 'sd_size', + 'sd_display', + 'tshm', + 'symph_mob', + 'relative_blade_length', + 'tht', + 'crowntype', + 'microwear', + 'horizodonty', + 'cusp_shape', + 'cusp_count_buccal', + 'cusp_count_lingual', + 'loph_count_lon', + 'loph_count_trs', + 'fct_al', + 'fct_ol', + 'fct_sf', + 'fct_ot', + 'fct_cm', + 'mesowear', + 'mw_or_high', + 'mw_or_low', + 'mw_cs_sharp', + 'mw_cs_round', + 'mw_cs_blunt', + 'mw_scale_min', + 'mw_scale_max', + 'mw_value', + 'pop_struc', + 'sp_comment', +] + +type SpeciesCopyField = (typeof SPECIES_COPY_FIELDS)[number] + +const isEmptyValue = (value: unknown) => value === null || value === undefined || value === '' + +const normalizeStringValue = (value: unknown) => (value === '' ? null : (value as string | number | null)) + +const normalizeBodyMass = (value: unknown) => { + if (typeof value === 'number') return Math.trunc(value) + if (value === null || value === undefined) return null + return value as number | null +} + +const buildSpeciesDisplayName = (species: { + genus_name: string | null + species_name: string | null + unique_identifier: string | null + species_id: number +}) => { + const name = `${species.genus_name ?? ''} ${species.species_name ?? ''}`.trim() + if (name) return name + if (species.unique_identifier) return species.unique_identifier + return `${species.species_id}` +} + +const appendSourceName = (sourceName: string | null, obsoleteName: string) => { + const trimmed = obsoleteName.trim() + if (!trimmed) return sourceName + if (!sourceName || sourceName.trim() === '') return trimmed + + const normalizedSource = sourceName.toLowerCase() + if (normalizedSource.includes(trimmed.toLowerCase())) return sourceName + + return `${sourceName}; ${trimmed}` +} + +const getMergeWriteHandler = () => { + return new WriteHandler({ + dbName: NOW_DB_NAME, + table: 'com_species', + idColumn: 'species_id', + allowedColumns: getFieldsOfTables(['com_species', 'now_ls', 'now_sau', 'now_sr', 'now_lau', 'com_taxa_synonym']), + type: 'update', + }) +} + +const buildOccurrenceSelect = () => { + const fields = OCCURRENCE_MERGE_FIELDS.reduce>((acc, field) => { + acc[field] = true + return acc + }, {}) + + return { + lid: true, + ...fields, + now_loc: { + select: { + loc_name: true, + country: true, + }, + }, + } +} + +const buildSpeciesSummary = async (speciesId: number): Promise => { + const species = await nowDb.com_species.findUnique({ + where: { species_id: speciesId }, + select: SPECIES_SUMMARY_FIELDS, + }) + + if (!species) return null + + const localities = await nowDb.now_ls.count({ where: { species_id: speciesId } }) + + return { + ...(species as SpeciesSummary), + localities, + } +} + +const buildSpeciesCopyValues = async ( + speciesId: number +): Promise | null> => { + const selectFields = SPECIES_COPY_FIELDS.reduce>((acc, field) => { + acc[field] = true + return acc + }, {}) + + const species = await nowDb.com_species.findUnique({ + where: { species_id: speciesId }, + select: selectFields, + }) + + return (species ?? null) as Record | null +} + +const buildSpeciesFieldChoices = ( + obsolete: Record, + accepted: Record +): SpeciesFieldChoice[] => { + return SPECIES_COPY_FIELDS.map(field => { + const obsoleteValue = obsolete[field] + const acceptedValue = accepted[field] + + const defaultChoice = isEmptyValue(acceptedValue) && !isEmptyValue(obsoleteValue) ? 'obsolete' : 'accepted' + + return { + field, + obsoleteValue, + acceptedValue, + defaultChoice, + } + }) +} + +type OccurrenceRow = Partial> & { + now_loc?: { loc_name: string | null; country: string | null } +} + +const buildOccurrenceConflict = ( + lid: number, + obsoleteRow: OccurrenceRow, + acceptedRow: OccurrenceRow +): OccurrenceConflict => { + const obsoleteValues = {} as Record + const acceptedValues = {} as Record + const defaultChoice = {} as Record + + for (const field of OCCURRENCE_MERGE_FIELDS) { + const obsoleteValue = obsoleteRow[field] + const acceptedValue = acceptedRow[field] + + obsoleteValues[field] = obsoleteValue ?? null + acceptedValues[field] = acceptedValue ?? null + defaultChoice[field] = isEmptyValue(acceptedValue) && !isEmptyValue(obsoleteValue) ? 'obsolete' : 'accepted' + } + + return { + lid, + localityName: acceptedRow.now_loc?.loc_name ?? obsoleteRow.now_loc?.loc_name ?? null, + country: acceptedRow.now_loc?.country ?? obsoleteRow.now_loc?.country ?? null, + obsolete: obsoleteValues, + accepted: acceptedValues, + defaultChoice, + } +} + +export const getSpeciesMergeSummary = async ( + obsoleteSpeciesId: number, + acceptedSpeciesId: number +): Promise => { + const [obsoleteSummary, acceptedSummary] = await Promise.all([ + buildSpeciesSummary(obsoleteSpeciesId), + buildSpeciesSummary(acceptedSpeciesId), + ]) + + if (!obsoleteSummary || !acceptedSummary) return null + + const [obsoleteCopyValues, acceptedCopyValues] = await Promise.all([ + buildSpeciesCopyValues(obsoleteSpeciesId), + buildSpeciesCopyValues(acceptedSpeciesId), + ]) + + if (!obsoleteCopyValues || !acceptedCopyValues) return null + + const [obsoleteRows, acceptedRows] = await Promise.all([ + nowDb.now_ls.findMany({ + where: { species_id: obsoleteSpeciesId }, + select: buildOccurrenceSelect(), + }), + nowDb.now_ls.findMany({ + where: { species_id: acceptedSpeciesId }, + select: buildOccurrenceSelect(), + }), + ]) + + const obsoleteByLid = new Map() + for (const row of obsoleteRows) obsoleteByLid.set(row.lid, row) + + const acceptedByLid = new Map() + for (const row of acceptedRows) acceptedByLid.set(row.lid, row) + + const occurrenceConflicts: OccurrenceConflict[] = [] + for (const [lid, obsoleteRow] of obsoleteByLid.entries()) { + const acceptedRow = acceptedByLid.get(lid) + if (!acceptedRow) continue + + occurrenceConflicts.push(buildOccurrenceConflict(lid, obsoleteRow, acceptedRow)) + } + + const speciesFieldChoices = buildSpeciesFieldChoices(obsoleteCopyValues, acceptedCopyValues) + + const result: SpeciesMergeSummaryResponse = { + obsolete: obsoleteSummary, + accepted: acceptedSummary, + speciesFieldChoices, + occurrenceConflicts, + } + + return JSON.parse(fixBigInt(result) ?? 'null') as SpeciesMergeSummaryResponse +} + +const validateReferences = async (references: Reference[]) => { + const validationError = referenceValidator(references) + if (validationError) { + throw new Error(validationError) + } + + const invalidReferences: number[] = [] + for (const reference of references) { + if (typeof reference.rid !== 'number') { + invalidReferences.push(reference.rid as number) + continue + } + const result = await getReferenceDetails(reference.rid) + if (!result) invalidReferences.push(reference.rid) + } + + if (invalidReferences.length > 0) { + throw new Error(`References with ID(s) ${invalidReferences.join(', ')} do not exist`) + } +} + +const buildSynonymKey = (genus: string | null, species: string | null) => + `${(genus ?? '').trim().toLowerCase()}|${(species ?? '').trim().toLowerCase()}` + +const buildOccurrenceFieldChoiceMap = (occurrenceFieldChoices: SpeciesMergeRequest['occurrenceFieldChoices']) => { + const choiceMap = new Map>() + for (const choice of occurrenceFieldChoices) { + choiceMap.set(choice.lid, choice.fieldChoice) + } + return choiceMap +} + +export const mergeSpecies = async (payload: SpeciesMergeRequest, user: User): Promise => { + const { + obsoleteSpeciesId, + acceptedSpeciesId, + selectedSpeciesFieldValues, + occurrenceFieldChoices, + addObsoleteAsSynonym, + synonymComment, + addSourceNameToOccurrences, + comment, + references, + } = payload + + if (!references || references.length === 0) { + throw new Error('References are required') + } + + await validateReferences(references) + + const [obsoleteSpecies, acceptedSpecies] = await Promise.all([ + nowDb.com_species.findUnique({ where: { species_id: obsoleteSpeciesId } }), + nowDb.com_species.findUnique({ where: { species_id: acceptedSpeciesId } }), + ]) + + if (!obsoleteSpecies || !acceptedSpecies) { + throw new Error('Species not found') + } + + const obsoleteName = buildSpeciesDisplayName(obsoleteSpecies) + + const [obsoleteOccurrences, acceptedOccurrences] = await Promise.all([ + nowDb.now_ls.findMany({ where: { species_id: obsoleteSpeciesId } }), + nowDb.now_ls.findMany({ where: { species_id: acceptedSpeciesId } }), + ]) + + const acceptedByLid = new Map() + for (const row of acceptedOccurrences) acceptedByLid.set(row.lid, row) + + const occurrenceChoiceMap = buildOccurrenceFieldChoiceMap(occurrenceFieldChoices) + + const writeHandler = getMergeWriteHandler() + + try { + await writeHandler.start() + writeHandler.idValue = acceptedSpeciesId + + const allowedSpeciesFieldSet = new Set(SPECIES_COPY_FIELDS) + const speciesUpdates: Record = {} + for (const [field, value] of Object.entries(selectedSpeciesFieldValues ?? {})) { + if (!allowedSpeciesFieldSet.has(field)) { + throw new Error(`Invalid species field: ${field}`) + } + if (value === undefined) continue + speciesUpdates[field] = normalizeStringValue(value) + } + + if (Object.keys(speciesUpdates).length > 0) { + await writeHandler.updateObject('com_species', { species_id: acceptedSpeciesId, ...speciesUpdates }, [ + 'species_id', + ]) + } + + for (const obsoleteRow of obsoleteOccurrences) { + const acceptedRow = acceptedByLid.get(obsoleteRow.lid) + if (acceptedRow) { + const choice = occurrenceChoiceMap.get(obsoleteRow.lid) + if (!choice) { + throw new Error(`Missing occurrence field choices for lid ${obsoleteRow.lid}`) + } + + const mergedRow = { ...acceptedRow } as Record + for (const field of OCCURRENCE_MERGE_FIELDS) { + const fieldChoice = choice[field] + if (!fieldChoice) { + throw new Error(`Missing occurrence field choice for lid ${obsoleteRow.lid}, field ${field}`) + } + const selectedValue = fieldChoice === 'obsolete' ? obsoleteRow[field] : acceptedRow[field] + mergedRow[field] = normalizeStringValue(selectedValue) + } + + mergedRow.source_name = addSourceNameToOccurrences + ? appendSourceName(mergedRow.source_name as string | null, obsoleteName) + : mergedRow.source_name + mergedRow.body_mass = normalizeBodyMass(mergedRow.body_mass) + + await writeHandler.updateObject('now_ls', mergedRow, ['lid', 'species_id']) + await writeHandler.deleteObject('now_ls', { lid: obsoleteRow.lid, species_id: obsoleteSpeciesId }, [ + 'lid', + 'species_id', + ]) + } else { + const newRow = { + ...obsoleteRow, + species_id: acceptedSpeciesId, + } as Record + + newRow.source_name = addSourceNameToOccurrences + ? appendSourceName(newRow.source_name as string | null, obsoleteName) + : newRow.source_name + newRow.body_mass = normalizeBodyMass(newRow.body_mass) + + await writeHandler.createObject('now_ls', newRow, ['lid', 'species_id']) + await writeHandler.deleteObject('now_ls', { lid: obsoleteRow.lid, species_id: obsoleteSpeciesId }, [ + 'lid', + 'species_id', + ]) + } + } + + const [obsoleteSynonyms, acceptedSynonyms] = await Promise.all([ + nowDb.com_taxa_synonym.findMany({ where: { species_id: obsoleteSpeciesId } }), + nowDb.com_taxa_synonym.findMany({ where: { species_id: acceptedSpeciesId } }), + ]) + + const acceptedSynonymKeys = new Set( + acceptedSynonyms.map(syn => buildSynonymKey(syn.syn_genus_name, syn.syn_species_name)) + ) + + const synonymsToAdd = obsoleteSynonyms + .filter(syn => !acceptedSynonymKeys.has(buildSynonymKey(syn.syn_genus_name, syn.syn_species_name))) + .map(syn => ({ + species_id: acceptedSpeciesId, + syn_genus_name: syn.syn_genus_name, + syn_species_name: syn.syn_species_name, + syn_comment: syn.syn_comment, + rowState: 'new' as const, + })) + + if (addObsoleteAsSynonym) { + const key = buildSynonymKey(obsoleteSpecies.genus_name, obsoleteSpecies.species_name) + if (!acceptedSynonymKeys.has(key)) { + synonymsToAdd.push({ + species_id: acceptedSpeciesId, + syn_genus_name: obsoleteSpecies.genus_name, + syn_species_name: obsoleteSpecies.species_name, + syn_comment: synonymComment ?? null, + rowState: 'new' as const, + }) + } + } + + if (synonymsToAdd.length > 0) { + await writeHandler.applyListChanges('com_taxa_synonym', synonymsToAdd, ['synonym_id', 'species_id']) + } + + await writeHandler.logUpdatesAndComplete(user.initials, comment, references) + } catch (error) { + await writeHandler.rollback() + await writeHandler.end() + throw error + } + + await deleteSpecies(obsoleteSpeciesId, user) + + const latestUpdate = await nowDb.now_sau.findFirst({ + where: { species_id: acceptedSpeciesId }, + orderBy: { suid: 'desc' }, + }) + + if (!latestUpdate) { + throw new Error('Merge succeeded but update log entry was not found') + } + + const date = latestUpdate.sau_date + ? latestUpdate.sau_date.toISOString().slice(0, 10) + : new Date().toISOString().slice(0, 10) + + return { + message: 'SPECIES MERGED SUCCESSFULLY', + suid: latestUpdate.suid, + coordinator: latestUpdate.sau_coordinator, + editor: latestUpdate.sau_authorizer, + date, + comment, + } +} diff --git a/backend/src/utils/db.ts b/backend/src/utils/db.ts index 0ea0c2d0..aecdbafb 100644 --- a/backend/src/utils/db.ts +++ b/backend/src/utils/db.ts @@ -3,7 +3,15 @@ import { logger } from './logger' import { PrismaClient as NowClient } from '../../prisma/generated/now_test_client' import { PrismaClient as LogClient } from '../../prisma/generated/now_log_test_client/default' import mariadb from 'mariadb' -import { MARIADB_HOST, MARIADB_PASSWORD, DB_CONNECTION_LIMIT, MARIADB_USER, RUNNING_ENV, LOG_DB_NAME } from './config' +import { + MARIADB_HOST, + MARIADB_PASSWORD, + DB_CONNECTION_LIMIT, + MARIADB_USER, + RUNNING_ENV, + LOG_DB_NAME, + MARIADB_PORT, +} from './config' import { readFile } from 'fs/promises' import { PathLike } from 'fs' @@ -162,6 +170,7 @@ export const getFieldsOfTables = (tables: string[]) => { export const pool = mariadb.createPool({ host: MARIADB_HOST, + port: MARIADB_PORT, password: MARIADB_PASSWORD, user: MARIADB_USER, connectionLimit: parseInt(DB_CONNECTION_LIMIT), @@ -218,6 +227,7 @@ export const resetTestDb = async () => { const createTestConnection = (dbName: string) => { return mariadb.createConnection({ host: MARIADB_HOST, + port: MARIADB_PORT, password: process.env.MARIADB_ROOT_PASSWORD, user: 'root', checkDuplicate: false, diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9a103211..fa8b6030 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -35,6 +35,16 @@ services: ports: - 127.0.0.1:3306:3306 + db-test: + container_name: nowdb-db-test + image: mariadb:11.4.3 + env_file: .test.env + volumes: + - ./test_data/:/docker-entrypoint-initdb.d + - nowdb-db-test:/var/lib/mysql/data + ports: + - 127.0.0.1:3307:3306 + phpmyadmin: restart: always image: phpmyadmin:5.2.1 @@ -48,3 +58,4 @@ services: volumes: nowdb-db-dev: + nowdb-db-test: diff --git a/documentation/codex_planning_prompt.md b/documentation/codex_planning_prompt.md index 7858a43a..4c87eaff 100644 --- a/documentation/codex_planning_prompt.md +++ b/documentation/codex_planning_prompt.md @@ -102,6 +102,13 @@ This section summarizes the overall architecture, structure, and technologies of - Typical CI failures to avoid: unchecked Prettier drift, unused imports/variables (especially in tests), missing async return types or implicit `any`, and stale `eslint-disable` directives. - Large fixtures or JSON assets (e.g., geodata) should remain excluded via `tsconfig.json`/`eslint.config.mjs` ignores; avoid importing them in type-checked code paths. +### Database Safety Guardrails (Mandatory) + +- **Never** run test resets (`/test/reset-test-database`) against the development database. +- Treat `test_data/sqlfiles/` as **test-only** fixtures. Development data should come from `data/sqlfiles/` or an explicitly provided dump. +- If dev and test are on the same MariaDB container/port, **do not** proceed with resets. Plan a separation step (second container/port or distinct DB host) before running tests that reset data. +- Before any DB reset/restore, confirm the **active backend env** and **DB connection target** (host, port, db names) to avoid unintended overwrites. + ### Frontend Unit-Test Planning Guardrails - When a feature touches shared frontend infrastructure such as `DetailView`, `EditableTable`, `DetailTabTable`, `UnsavedChangesProvider`, Redux API slices, or route-aware components, include explicit test-harness tasks in the plan. diff --git a/documentation/coding_prompt_template_fullstack.md b/documentation/coding_prompt_template_fullstack.md index 8f9147d3..2f0698ce 100644 --- a/documentation/coding_prompt_template_fullstack.md +++ b/documentation/coding_prompt_template_fullstack.md @@ -85,6 +85,9 @@ Implement **exactly one task** from an approved feature plan. ### Backend API-test checklist (mandatory when backend code or backend API tests change) - Prefer deterministic backend tests. Do not leave API tests dependent on live external services, network access, or unstable third-party result counts. Mock outbound integrations such as Geonames at the test boundary. +- Never run `/test/reset-test-database` against the development database. Ensure test resets target an isolated test DB host/port. +- Treat `test_data/sqlfiles/` as **test-only** fixtures. Development data must not be overwritten by test resets. +- Before invoking any DB reset/restore command, verify the backend’s **active env** and **DB connection target** (host, port, db names). - Use seeded ids and fixture values that are actually present in the checked-in test SQL. If a test depends on seed data, verify those ids/rows before changing expectations. - When changing shared backend write helpers, assume the blast radius is large. Check how the change affects generated ids, update-log entry creation, reference join rows, and metadata stripping across locality/species/time-unit/time-bound flows. - Strip helper-only metadata before generic persistence utilities. Route/test payloads may include fields such as `comment`, `references`, `up_bound`, `low_bound`, or UI-only helper objects that must not be treated as DB columns. diff --git a/documentation/devops/database.md b/documentation/devops/database.md index 74acdebd..482164dc 100644 --- a/documentation/devops/database.md +++ b/documentation/devops/database.md @@ -4,6 +4,14 @@ The mariadb-container by default executes all .sql files it finds in the containers `/docker-entrypoint-initdb.d/` directory when the container is run the first time. The `data` directory is mounted there. It contains an initialization file (`restore_and_create.sql`) which will then restore the database from the sql-dumpfiles in `sqlfiles/`, and also create a user `'now_test'@'localhost'` with a password defined as MARIADB_PASSWORD in the .db.dev.env file for the database. +**Dev vs Test database separation (important)** + +- Development data is restored from `data/sqlfiles/` and runs in the `nowdb-db-dev` container on port `3306`. +- Test data is restored from `test_data/sqlfiles/` and runs in a separate `nowdb-db-test` container on port `3307`. +- Never run `/test/reset-test-database` against the dev database. It must target the test container only. +- Ensure `.test.env` points to port `3307` so API tests do not overwrite dev data. +- `resetTestDb` now uses `MARIADB_PORT`, so keep `.test.env` aligned with the test DB port. + **Accessing database inside docker** `docker exec -it nowdb-db mariadb -u now_test -p --database now_test` diff --git a/documentation/devops/setup.md b/documentation/devops/setup.md index e14bdafb..51d976e0 100644 --- a/documentation/devops/setup.md +++ b/documentation/devops/setup.md @@ -14,7 +14,8 @@ Requirements: npm and some modern installation of Docker engine with Docker comp 1. `npm run setup` in project root to install all node modules, generate prisma-client and create all required config files (doesn't overwrite previous ones!). 2. If you wish to use the staging database, you need to copy the dump files into `data/sqlfiles/` directory. See example of how anonymized test data is in `test_data/sqlfiles/` -3. `npm run dev` to run with the staging database or `npm run start:anon` to run with the anonymized small test-db included in the repository. +3. `npm run dev` starts **both** the dev database (`nowdb-db-dev`) and the test database (`nowdb-db-test`) so API tests can reset safely without overwriting dev data. + - You can also start just the test database with `npm run dev:db-test` and stop it with `npm run dev:db-test:down`. 4. Check if it worked: The docker logs should have a line saying database connection works. Open `localhost:5173` in browser to check if frontend works and shows data. **Errors while setting up** diff --git a/documentation/functionality/species_merge.md b/documentation/functionality/species_merge.md new file mode 100644 index 00000000..85ed69a3 --- /dev/null +++ b/documentation/functionality/species_merge.md @@ -0,0 +1,68 @@ +# Species Merge + +The Species Merge tool replaces all obsolete species ids with a selected accepted species id, while preserving +occurrence data, synonyms, and update logs. This flow is **Admin-only** and uses the same reference-selection and +save procedures as other DetailView updates. + +## UI Flow Summary + +1. Select an **Obsolete taxonomy** and an **Accepted taxonomy**. +2. Review the **Selected taxonomies** table (side-by-side values on the same row). +3. Choose whether to copy selected parameters from obsolete to accepted. +4. Decide whether to append the obsolete species name to locality-species `source_name` values. +5. Resolve occurrence conflicts (only shown when obsolete values exist and differ from accepted). +6. Decide whether to add the obsolete species as a synonym (optional comment). +7. Proceed to **Reference selection** (staging view) and save. + +After a successful merge, the UI navigates to the accepted species detail page. + +## Selected Taxonomies Table + +- Displayed as a single table with columns: **Field**, **Obsolete taxonomy**, **Accepted taxonomy**. +- Field names are bold and rows are striped for quick comparison. + +## Copy Selected Parameters + +The copy list is intentionally tight: +- **Shows only** fields where the **obsolete value is non-empty** and **differs** from the accepted value. +- **Taxonomic fields are excluded** (see list below). +- Additional excluded fields: `class_name`, `used_morph`, `used_now`, `used_gene`, `sp_status`. + +When the admin selects the obsolete value for a field, the accepted species is updated with that value during merge. + +### Taxonomic Fields Excluded + +- `order_name` +- `family_name` +- `genus_name` +- `species_name` +- `subclass_or_superorder_name` +- `suborder_or_superfamily_name` +- `subfamily_name` +- `unique_identifier` + +## Occurrence Conflict Resolution + +Occurrence conflicts are listed per locality only when: +- the obsolete value **exists**, and +- the obsolete value is **different** from the accepted value. + +Selections are applied during merge to update accepted occurrence rows and migrate obsolete rows. + +## References and Save + +The merge flow uses the same staging/reference selection flow as other entity edits: +- References are **required**. +- Save triggers the merge and logs updates in the update log tables. + +## API Endpoints + +Backend endpoints are defined in `backend/src/routes/speciesMerge.ts` and implemented in +`backend/src/services/speciesMerge.ts`: + +- `GET /admin/species-merge/summary?obsoleteId=&acceptedId=` +- `POST /admin/species-merge` + +Payload and response shapes are defined in `frontend/src/shared/types` under `SpeciesMergeRequest`, +`SpeciesMergeSummary`, and `SpeciesMergeResponse`. + diff --git a/documentation/user_rights.md b/documentation/user_rights.md index a83ef8f0..036c7615 100644 --- a/documentation/user_rights.md +++ b/documentation/user_rights.md @@ -40,6 +40,7 @@ Users with the **EditUnrestricted** role are not limited by project membership w - Creating or updating a species (and synonyms) requires the **Admin**, **EditUnrestricted**, or **EditRestricted** roles. - Deleting a species requires the **Admin** or **EditUnrestricted** roles. - Deleting a species synonym requires the **Admin**, **EditUnrestricted**, or **EditRestricted** roles. +- Merging species requires the **Admin** role (via `/admin/species-merge`). ## References diff --git a/frontend/src/components/DetailView/components.tsx b/frontend/src/components/DetailView/components.tsx index 9de9a9fe..6223854f 100755 --- a/frontend/src/components/DetailView/components.tsx +++ b/frontend/src/components/DetailView/components.tsx @@ -20,10 +20,12 @@ export const WriteButton = ({ onWrite, taxonomy, hasStagingMode = false, + disabled = false, }: { onWrite: (editData: EditDataType, setEditData: (editData: EditDataType) => void) => Promise taxonomy?: boolean hasStagingMode?: boolean + disabled?: boolean }) => { const { data, editData, setEditData, mode, setMode, validator, fieldsWithErrors, setFieldsWithErrors, isDirty } = useDetailContext() @@ -187,6 +189,7 @@ export const WriteButton = ({ } const handleWriteButtonClick = async () => { + if (disabled) return const finalize = async () => { if (taxonomy) { setLoading(true) @@ -240,7 +243,7 @@ export const WriteButton = ({ > + + {mergeResult ? ( + + SPECIES MERGED SUCCESSFULLY + SUID {mergeResult.suid} + Coordinator {mergeResult.coordinator} + Editor {mergeResult.editor} + Date {mergeResult.date} + Comment {mergeResult.comment} + + ) : null} + + ) : null} + + {mode.staging ? ( + <> + + + Reference Selection + + + + + Merge summary + + + + Obsolete species + + {formatSpeciesLabel(obsoleteSpecies)} + + + + Accepted species + + {formatSpeciesLabel(acceptedSpecies)} + + + + + + You are about to merge and remove the obsolete species. This action will update occurrences and cannot + be easily undone. + + + + Merge checklist + {!checklistComplete ? ( + + + + Please complete the checklist before saving. + + + ) : null} + + + setMergeConfirmations(prev => ({ ...prev, reviewedDetails: event.target.checked })) + } + /> + } + label={ + + {!mergeConfirmations.reviewedDetails ? ( + + ) : null} + + I reviewed the merge details and parameter selections. + + + } + /> + + setMergeConfirmations(prev => ({ ...prev, confirmRemoval: event.target.checked })) + } + /> + } + label={ + + {!mergeConfirmations.confirmRemoval ? ( + + ) : null} + + I understand the obsolete species will be removed after saving. + + + } + /> + + + + + + {!mergeConfirmations.reviewedDetails || !mergeConfirmations.confirmRemoval ? ( + + Complete the checklist above to enable saving. + + ) : ( + + )} + + + + + + + + + ) : null} + + + + ) +} + +export default SpeciesMergePage diff --git a/frontend/src/redux/speciesReducer.ts b/frontend/src/redux/speciesReducer.ts index f57fbaa9..b42aa68d 100755 --- a/frontend/src/redux/speciesReducer.ts +++ b/frontend/src/redux/speciesReducer.ts @@ -1,5 +1,13 @@ import { api } from './api' -import { EditDataType, Species, SpeciesDetailsType, SpeciesSynonym } from '@/shared/types' +import { + EditDataType, + Species, + SpeciesDetailsType, + SpeciesMergeRequest, + SpeciesMergeResponse, + SpeciesMergeSummary, + SpeciesSynonym, +} from '@/shared/types' const speciesApi = api.injectEndpoints({ endpoints: builder => ({ @@ -39,6 +47,24 @@ const speciesApi = api.injectEndpoints({ invalidatesTags: (result, _error, species_id) => typeof result !== 'undefined' ? [{ type: 'species', id: species_id }, 'specieslist', 'speciessynonyms'] : [], }), + getSpeciesMergeSummary: builder.query({ + query: ({ obsoleteId, acceptedId }) => ({ + url: `/admin/species-merge/summary`, + params: { obsoleteId, acceptedId }, + }), + }), + mergeSpecies: builder.mutation({ + query: payload => ({ + url: `/admin/species-merge`, + method: 'POST', + body: payload, + }), + invalidatesTags: (_result, _error, { acceptedSpeciesId }) => [ + { type: 'species', id: acceptedSpeciesId }, + 'specieslist', + 'speciessynonyms', + ], + }), }), }) @@ -48,4 +74,6 @@ export const { useGetSpeciesDetailsQuery, useEditSpeciesMutation, useDeleteSpeciesMutation, + useGetSpeciesMergeSummaryQuery, + useMergeSpeciesMutation, } = speciesApi diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index a00609f1..1db99bc5 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -67,6 +67,13 @@ const router = createBrowserRouter([ return { Component: EmailPage } }, }, + { + path: 'admin/merge-species', + lazy: async () => { + const { SpeciesMergePage } = await import('../pages/admin/SpeciesMergePage') + return { Component: SpeciesMergePage } + }, + }, { path: 'login', lazy: async () => { diff --git a/frontend/src/shared/types/data.ts b/frontend/src/shared/types/data.ts index 94786a18..eaea1257 100755 --- a/frontend/src/shared/types/data.ts +++ b/frontend/src/shared/types/data.ts @@ -547,6 +547,157 @@ export type Reference = { title_secondary: string } +export type OccurrenceMergeField = + | 'nis' + | 'pct' + | 'quad' + | 'mni' + | 'qua' + | 'id_status' + | 'orig_entry' + | 'source_name' + | 'body_mass' + | 'mesowear' + | 'mw_or_high' + | 'mw_or_low' + | 'mw_cs_sharp' + | 'mw_cs_round' + | 'mw_cs_blunt' + | 'mw_scale_min' + | 'mw_scale_max' + | 'mw_value' + | 'microwear' + | 'dc13_mean' + | 'dc13_n' + | 'dc13_max' + | 'dc13_min' + | 'dc13_stdev' + | 'do18_mean' + | 'do18_n' + | 'do18_max' + | 'do18_min' + | 'do18_stdev' + +export type SpeciesMergeSummaryField = { + species_id: number + class_name: string | null + order_name: string | null + family_name: string | null + genus_name: string | null + species_name: string | null + subclass_or_superorder_name: string | null + suborder_or_superfamily_name: string | null + subfamily_name: string | null + unique_identifier: string | null + taxonomic_status: string | null + common_name: string | null + sp_author: string | null + strain: string | null + gene: string | null + taxon_status: string | null + diet_description: string | null + rel_fib: string | null + selectivity: string | null + digestion: string | null + feedinghab1: string | null + feedinghab2: string | null + shelterhab1: string | null + shelterhab2: string | null + hunt_forage: string | null + brain_mass: number | null + activity: string | null + symph_mob: string | null + relative_blade_length: number | null + microwear: string | null + horizodonty: string | null + cusp_shape: string | null + cusp_count_buccal: string | null + cusp_count_lingual: string | null + loph_count_lon: string | null + loph_count_trs: string | null + fct_al: string | null + fct_ol: string | null + fct_sf: string | null + fct_ot: string | null + fct_cm: string | null + mesowear: string | null + mw_or_high: number | null + mw_or_low: number | null + mw_cs_sharp: number | null + mw_cs_round: number | null + mw_cs_blunt: number | null + mw_scale_min: number | null + mw_scale_max: number | null + mw_value: number | null + pop_struc: string | null + sp_status: boolean | null + used_morph: boolean | null + used_now: boolean | null + used_gene: boolean | null + sp_comment: string | null + localities: number + sv_length: string | null + body_mass: number | null + sd_size: string | null + sd_display: string | null + tshm: string | null + tht: string | null + crowntype: string | null + diet1: string | null + diet2: string | null + diet3: string | null + locomo1: string | null + locomo2: string | null + locomo3: string | null +} + +export type SpeciesMergeFieldChoice = { + field: string + obsoleteValue: string | number | boolean | null + acceptedValue: string | number | boolean | null + defaultChoice: 'accepted' | 'obsolete' +} + +export type SpeciesMergeConflict = { + lid: number + localityName: string | null + country: string | null + obsolete: Record + accepted: Record + defaultChoice: Record +} + +export type SpeciesMergeSummary = { + obsolete: SpeciesMergeSummaryField + accepted: SpeciesMergeSummaryField + speciesFieldChoices: SpeciesMergeFieldChoice[] + occurrenceConflicts: SpeciesMergeConflict[] +} + +export type SpeciesMergeRequest = { + obsoleteSpeciesId: number + acceptedSpeciesId: number + selectedSpeciesFieldValues: Record + occurrenceFieldChoices: Array<{ + lid: number + fieldChoice: Record + }> + addObsoleteAsSynonym: boolean + synonymComment?: string + addSourceNameToOccurrences: boolean + comment: string + references: Reference[] +} + +export type SpeciesMergeResponse = { + message: string + suid: number + coordinator: string + editor: string + date: string + comment: string +} + export type ReferenceOfUpdate = Prisma.ref_ref & { ref_authors: Prisma.ref_authors[] ref_journal: Prisma.ref_journal diff --git a/frontend/src/tests/pages/SpeciesMergePage.test.tsx b/frontend/src/tests/pages/SpeciesMergePage.test.tsx new file mode 100644 index 00000000..6966eb9d --- /dev/null +++ b/frontend/src/tests/pages/SpeciesMergePage.test.tsx @@ -0,0 +1,227 @@ +import { describe, expect, it, jest } from '@jest/globals' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { SpeciesMergePage } from '@/pages/admin/SpeciesMergePage' +import { Role, Species, SpeciesMergeSummary, SpeciesMergeSummaryField, Reference } from '@/shared/types' + +const mockUseUser = jest.fn() +const mockUseNotify = jest.fn() +const mockUseGetAllSpeciesQuery = jest.fn() +const mockUseGetSpeciesMergeSummaryQuery = jest.fn() +const mockUseMergeSpeciesMutation = jest.fn() +const mockUseGetAllReferencesQuery = jest.fn() + +jest.mock('@/hooks/user', () => ({ + useUser: () => mockUseUser(), +})) + +jest.mock('@/hooks/notification', () => ({ + useNotify: () => ({ notify: mockUseNotify }), +})) + +jest.mock('@/redux/speciesReducer', () => ({ + useGetAllSpeciesQuery: () => mockUseGetAllSpeciesQuery(), + useGetSpeciesMergeSummaryQuery: () => mockUseGetSpeciesMergeSummaryQuery(), + useMergeSpeciesMutation: () => mockUseMergeSpeciesMutation(), +})) + +jest.mock('@/redux/referenceReducer', () => ({ + useGetAllReferencesQuery: () => mockUseGetAllReferencesQuery(), +})) + +const speciesFactory = (overrides: Partial = {}): Species => ({ + species_id: 1, + order_name: 'Order', + family_name: 'Family', + genus_name: 'Genus', + species_name: 'species', + subclass_or_superorder_name: null, + suborder_or_superfamily_name: null, + subfamily_name: null, + unique_identifier: 'id', + taxonomic_status: null, + sv_length: null, + body_mass: 1, + sd_size: null, + sd_display: null, + tshm: null, + tht: null, + horizodonty: null, + crowntype: null, + cusp_shape: null, + cusp_count_buccal: null, + cusp_count_lingual: null, + loph_count_lon: null, + loph_count_trs: null, + fct_al: null, + fct_ol: null, + fct_sf: null, + fct_ot: null, + fct_cm: null, + microwear: null, + mesowear: null, + mw_or_high: 0, + mw_or_low: 0, + mw_cs_sharp: 0, + mw_cs_round: 0, + mw_cs_blunt: 0, + diet1: null, + diet2: null, + diet3: null, + locomo1: null, + locomo2: null, + locomo3: null, + sp_author: null, + sp_comment: null, + has_synonym: false, + has_no_locality: false, + synonyms: [], + ...overrides, +}) + +const referenceFactory = (overrides: Partial = {}): Reference => ({ + rid: 100, + title_primary: 'Reference title', + title_secondary: '', + date_primary: 2000, + ref_authors: [{ au_num: 1, author_surname: 'Doe', author_initials: 'J' }], + ref_journal: { journal_title: 'Journal' }, + ref_ref_type: { ref_type: 'Article' }, + ...overrides, +}) + +const summaryFieldFactory = (overrides: Partial = {}): SpeciesMergeSummaryField => ({ + species_id: 0, + class_name: null, + order_name: null, + family_name: null, + genus_name: null, + species_name: null, + subclass_or_superorder_name: null, + suborder_or_superfamily_name: null, + subfamily_name: null, + unique_identifier: null, + taxonomic_status: null, + common_name: null, + sp_author: null, + strain: null, + gene: null, + taxon_status: null, + diet_description: null, + rel_fib: null, + selectivity: null, + digestion: null, + feedinghab1: null, + feedinghab2: null, + shelterhab1: null, + shelterhab2: null, + hunt_forage: null, + brain_mass: null, + activity: null, + symph_mob: null, + relative_blade_length: null, + microwear: null, + horizodonty: null, + cusp_shape: null, + cusp_count_buccal: null, + cusp_count_lingual: null, + loph_count_lon: null, + loph_count_trs: null, + fct_al: null, + fct_ol: null, + fct_sf: null, + fct_ot: null, + fct_cm: null, + mesowear: null, + mw_or_high: null, + mw_or_low: null, + mw_cs_sharp: null, + mw_cs_round: null, + mw_cs_blunt: null, + mw_scale_min: null, + mw_scale_max: null, + mw_value: null, + pop_struc: null, + sp_status: null, + used_morph: null, + used_now: null, + used_gene: null, + sp_comment: null, + localities: 0, + sv_length: null, + body_mass: null, + sd_size: null, + sd_display: null, + tshm: null, + tht: null, + crowntype: null, + diet1: null, + diet2: null, + diet3: null, + locomo1: null, + locomo2: null, + locomo3: null, + ...overrides, +}) + +const summaryFactory = (): SpeciesMergeSummary => ({ + obsolete: summaryFieldFactory({ + species_id: 1, + order_name: 'Order', + family_name: 'Family', + genus_name: 'Genus', + species_name: 'species', + unique_identifier: 'id', + localities: 1, + body_mass: 1, + }), + accepted: summaryFieldFactory({ + species_id: 2, + order_name: 'Order', + family_name: 'Family', + genus_name: 'Genus', + species_name: 'species2', + unique_identifier: 'id2', + localities: 2, + body_mass: 2, + }), + speciesFieldChoices: [], + occurrenceConflicts: [], +}) + +describe('SpeciesMergePage', () => { + it('blocks non-admin users', () => { + mockUseUser.mockReturnValue({ role: Role.ReadOnly }) + mockUseGetAllSpeciesQuery.mockReturnValue({ data: [], isError: false }) + mockUseGetAllReferencesQuery.mockReturnValue({ data: [], isError: false }) + mockUseGetSpeciesMergeSummaryQuery.mockReturnValue({ data: undefined, isFetching: false, isError: false }) + mockUseMergeSpeciesMutation.mockReturnValue([jest.fn(), { isLoading: false, data: undefined }]) + + render( + + + + ) + + expect(screen.getByText('Only administrators can access the Species Merge tool.')).toBeTruthy() + }) + + it('renders the selection UI for admins', () => { + mockUseUser.mockReturnValue({ role: Role.Admin }) + mockUseGetAllSpeciesQuery.mockReturnValue({ data: [speciesFactory()], isError: false }) + mockUseGetAllReferencesQuery.mockReturnValue({ data: [referenceFactory()], isError: false }) + mockUseGetSpeciesMergeSummaryQuery.mockReturnValue({ data: summaryFactory(), isFetching: false, isError: false }) + mockUseMergeSpeciesMutation.mockReturnValue([jest.fn(), { isLoading: false, data: undefined }]) + + render( + + + + ) + + expect(screen.getByText('Merge Species (Admin)')).toBeTruthy() + expect(screen.getByLabelText('Obsolete taxonomy')).toBeTruthy() + expect(screen.getByLabelText('Accepted taxonomy')).toBeTruthy() + }) +}) diff --git a/package.json b/package.json index e3ee7b6f..8bad4e21 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "start:anon": "docker compose -f docker-compose.anon.yml up", "start:anon:api": "docker compose -f docker-compose.anon.yml up db-anon", "dev": "docker compose -f docker-compose.dev.yml up", + "dev:db-test": "docker compose -f docker-compose.dev.yml up -d db-test", + "dev:db-test:down": "docker compose -f docker-compose.dev.yml stop db-test", "dev:down": "docker compose -f docker-compose.dev.yml down -v", "anon:down": "docker compose -f docker-compose.anon.yml down -v", "check": "echo \"Running checks for both services and cypress tests...\" && npm run tsc && npm run lint && echo \"All good! :)\"",