update project update process in frontend and backend, and add project validators (#1230)

* update project update prosess in frontend, and add project validators

* change project update process in backend

* update project backend files, projectReducer, project validators and tests

* update project api tests, and error handling in ProjectDetails

* update frontend project creation form

* remove ProjectEditPage tests and fix ProjectNewPage tests

* update project api tests, project write process, and project validators

* remove unneeded files relating to projects, and merge project/ and projects/ route files

* remove project validators from backend, and update other project files

* allow all users to view projects table

* fix import issue

* update project api tests to use correct url, and add --runInBand flag to backend unit tests

* update cypress test
This commit is contained in:
juhanikat
2026-08-19 11:21:36 +03:00
committed by GitHub
parent c3eeb10262
commit 5c065508b4
39 changed files with 854 additions and 1212 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "npx tsx watch --clear-screen=false src/index.ts",
"build": "tsc",
"test:api": "jest src/api-tests --runInBand --coverage --config jest-config.js --detectOpenHandles --forceExit --silent",
"test:unit": "jest src/unit-tests --coverage --config jest-config.js --detectOpenHandles --forceExit --silent",
"test:unit": "jest src/unit-tests --runInBand --coverage --config jest-config.js --detectOpenHandles --forceExit --silent",
"test:api:local": "DOTENV_CONFIG_PATH=../.test.env npm run test:api -- --setupFiles dotenv/config",
"start": "node build/index.js",
"lint": "eslint --report-unused-disable-directives --max-warnings 0",
@@ -0,0 +1,134 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
import { ProjectDetailsType } from '../../../../frontend/src/shared/types'
import { pool } from '../../utils/db'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import {
existingPerson,
invalidMemberArrayError1,
invalidMemberArrayError2,
newProjectBasis,
newProjectBasisWithoutCoordinator,
newProjectBasisWithoutMemberArray,
noCoordinatorError,
noMemberArrayError,
} from './data'
let createdProject: ProjectDetailsType | null = null
describe('Creating new project works', () => {
beforeAll(async () => {
await resetDatabase()
}, resetDatabaseTimeout)
beforeEach(async () => {
await login()
})
afterAll(async () => {
await pool.end()
})
it('Request succeeds and returns valid number id', async () => {
const { body: resultBody, status: getReqStatus } = await send<{ pid: number }>('project/', 'PUT', {
project: newProjectBasis,
})
const { pid: createdId } = resultBody
expect(typeof createdId).toEqual('number')
expect(getReqStatus).toEqual(200)
const { body, status: getReqStat } = await send<ProjectDetailsType>(`project/${createdId}`, 'GET')
expect(getReqStat).toEqual(200)
createdProject = body
})
it('Contains correct data', () => {
const { pid, proj_name, proj_code, proj_status, proj_records } = createdProject!
expect(pid).toBeDefined()
expect(proj_name).toEqual(newProjectBasis.proj_name)
expect(proj_code).toEqual(newProjectBasis.proj_code)
expect(proj_status).toEqual(newProjectBasis.proj_status)
expect(proj_records).toEqual(newProjectBasis.proj_records)
})
it('Creation succeeds with empty member list', async () => {
const { status: getReqStatus } = await send<{ pid: number }>('project/', 'PUT', {
project: { ...newProjectBasis, now_proj_people: [] },
})
expect(getReqStatus).toEqual(200)
})
it('Creation fails with no member list', async () => {
const { body: resultBody, status: getReqStatus } = await send('project/', 'PUT', {
project: { ...newProjectBasisWithoutMemberArray },
})
//expect(typeof resultBody).toBe('array')
expect(resultBody).toContainEqual(noMemberArrayError)
expect(getReqStatus).toEqual(403)
})
it('Creation succeeds with empty member list', async () => {
const { status: getReqStatus } = await send<{ pid: number }>('project/', 'PUT', {
project: { ...newProjectBasis, now_proj_people: [] },
})
expect(getReqStatus).toEqual(200)
})
it('Creation fails with no coordinator', async () => {
const { body: resultBody, status: getReqStatus } = await send<{ pid: number }>('project/', 'PUT', {
project: { ...newProjectBasisWithoutCoordinator },
})
//expect(typeof resultBody).toBe('array')
expect(resultBody).toContainEqual(noCoordinatorError)
expect(getReqStatus).toEqual(403)
})
it('Creation fails with coordinator that does not have a matching user', async () => {
const { status: getReqStatus } = await send<{ pid: number }>('project/', 'PUT', {
project: { ...newProjectBasis, contact: 'NOTEXIST' },
})
expect(getReqStatus).toEqual(500)
})
it('Creation fails for non-admin users', async () => {
logout()
const { body: resultBodyAnon, status: resultStatusAnon } = await send<{ pid: number }>('project/', 'PUT', {
project: newProjectBasis,
})
expect(resultBodyAnon).toEqual(noPermError)
expect(resultStatusAnon).toEqual(403)
await login('testEr')
const { body: resultBodyEr, status: resultStatusEr } = await send<{ pid: number }>('project/', 'PUT', {
project: newProjectBasis,
})
expect(resultBodyEr).toEqual(noPermError)
expect(resultStatusEr).toEqual(403)
await login('testEu')
const { body: resultBodyEu, status: resultStatusEu } = await send<{ pid: number }>('project/', 'PUT', {
project: newProjectBasis,
})
expect(resultBodyEu).toEqual(noPermError)
expect(resultStatusEu).toEqual(403)
})
it('Creation fails with members without required fields', async () => {
const { body: putReqBody, status: putReqStatus } = await send('project/', 'PUT', {
project: { ...newProjectBasis, now_proj_people: [{ initials: existingPerson.initials }] },
})
expect(putReqStatus).toEqual(403)
expect(putReqBody).toHaveLength(1)
expect((putReqBody as Array<object>)[0]).toEqual(invalidMemberArrayError1)
const { body: putReqBody2, status: putReqStatus2 } = await send('project/', 'PUT', {
project: { ...newProjectBasis, now_proj_people: [{ com_people: existingPerson }] },
})
expect(putReqStatus2).toEqual(403)
expect(putReqBody2).toHaveLength(1)
expect((putReqBody2 as Array<object>)[0]).toEqual(invalidMemberArrayError2)
})
})
+95
View File
@@ -0,0 +1,95 @@
import { PersonDetailsType } from '../../../../frontend/src/shared/types'
export const existingPerson: Omit<PersonDetailsType, 'user' | 'project_relations' | 'coordinator_relations'> = {
initials: 'AD',
first_name: 'adf',
surname: 'ads',
full_name: 'adf ads',
format: null,
email: 'email',
user_id: 156,
organization: 'organization',
country: 'Finland',
password_set: new Date('2024-05-22T00:00:00.000Z'),
used_morph: null,
used_now: null,
used_gene: null,
now_user_group: 'eu',
}
export const existingPerson2: Omit<PersonDetailsType, 'user' | 'project_relations' | 'coordinator_relations'> = {
initials: 'CO',
first_name: 'cfn',
surname: 'csn',
full_name: 'cfn csn',
format: null,
email: 'email',
user_id: 162,
organization: 'organization',
country: 'Finland',
password_set: new Date('2024-05-22T00:00:00.000Z'),
used_morph: null,
used_now: null,
used_gene: null,
now_user_group: 'eu',
}
export const newProjectBasis = {
proj_name: 'LATER Database',
proj_code: 'LATER',
contact: 'AD',
proj_status: 'current',
proj_records: true,
now_proj_people: [{ pid: 3, initials: 'AD', com_people: existingPerson }],
references: [],
comment: '',
}
export const editedProject = {
...newProjectBasis,
pid: 3,
proj_name: 'edited project name',
proj_code: 'LATER2',
}
export const newProjectBasisWithoutMemberArray = {
proj_name: 'LATER Database',
proj_code: 'LATER',
contact: 'AD',
proj_status: 'current',
proj_records: true,
references: [],
comment: '',
}
export const newProjectBasisWithInvalidMembers = {
proj_name: 'LATER Database',
proj_code: 'LATER',
contact: 'AD',
proj_status: 'current',
proj_records: true,
now_proj_people: [{ pid: 3, initials: 'AD' }],
references: [],
comment: '',
}
export const newProjectBasisWithoutCoordinator = {
proj_name: 'LATER Database',
proj_code: 'LATER',
proj_status: 'current',
proj_records: true,
now_proj_people: [{ pid: 3, initials: 'AD' }],
references: [],
comment: '',
}
export const noCoordinatorError = { name: 'Coordinator', error: 'This field is required' }
export const noMemberArrayError = { name: 'Members', error: 'This field is required' }
export const invalidMemberArrayError1 = {
name: 'Members',
error: 'Project members gave the following errors: Member must have a com_people object with valid user ID',
}
export const invalidMemberArrayError2 = {
name: 'Members',
error: 'Project members gave the following errors: Member initials must be a non-empty string',
}
+41 -30
View File
@@ -1,65 +1,76 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { pool } from '../../utils/db'
import { ProjectDetailsType } from '../../../../frontend/src/shared/types'
describe('DELETE /project/:id', () => {
describe('Deleting project works', () => {
beforeAll(async () => {
await resetDatabase()
}, resetDatabaseTimeout)
beforeEach(async () => {
await login()
})
afterAll(async () => {
await pool.end()
})
it('allows admins to delete a project and related data', async () => {
await login('testSu', 'test')
const createdProject = await send<{ pid: number }>('projects', 'POST', {
projectCode: 'DEL-001',
projectName: 'Project To Delete',
coordinatorUserId: 163,
memberUserIds: [167],
})
const deleted = await send(`project/${createdProject.body.pid}`, 'DELETE')
const { body: existingProject, status: getReqStatus } = await send<ProjectDetailsType>('project/14', 'GET')
expect(getReqStatus).toEqual(200)
expect(existingProject.pid).toEqual(14)
const deleted = await send(`project/${existingProject.pid}`, 'DELETE')
expect(deleted.status).toEqual(200)
const fetchAfterDelete = await send(`project/${createdProject.body.pid}`, 'GET')
const fetchAfterDelete = await send(`project/${existingProject.pid}`, 'GET')
expect(fetchAfterDelete.status).toEqual(404)
})
it('rejects deletion attempts from non-admin users', async () => {
await login('testSu', 'test')
const createdProject = await send<{ pid: number }>('projects', 'POST', {
projectCode: 'DEL-002',
projectName: 'Unauthorized Delete',
coordinatorUserId: 163,
})
it('project members are not deleted along with the project', async () => {
const { body: existingProject, status: getReqStatus } = await send<ProjectDetailsType>('project/3', 'GET')
expect(getReqStatus).toEqual(200)
expect(existingProject.pid).toEqual(3)
expect(existingProject.now_proj_people[0]).toHaveProperty('com_people')
await login('testPl', 'test')
const plDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE')
expect(plDeleteAttempt.status).toEqual(403)
expect(plDeleteAttempt.body).toEqual(noPermError)
await login('testSu', 'test')
const deleted = await send(`project/${existingProject.pid}`, 'DELETE')
expect(deleted.status).toEqual(200)
const memberFetchAfterDelete = await send(
`person/${existingProject.now_proj_people[0].com_people!.initials}`,
'GET'
)
expect(memberFetchAfterDelete.status).toEqual(200)
})
it('rejects deletion attempts from non-admin users', async () => {
const { body: existingProject, status: getReqStatus } = await send<ProjectDetailsType>('project/23', 'GET')
expect(getReqStatus).toEqual(200)
expect(existingProject.pid).toEqual(23)
await login('testEu', 'test')
const euDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE')
const euDeleteAttempt = await send(`project/${existingProject.pid}`, 'DELETE')
expect(euDeleteAttempt.status).toEqual(403)
expect(euDeleteAttempt.body).toEqual(noPermError)
await login('testEr', 'test')
const erDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE')
const erDeleteAttempt = await send(`project/${existingProject.pid}`, 'DELETE')
expect(erDeleteAttempt.status).toEqual(403)
expect(erDeleteAttempt.body).toEqual(noPermError)
logout()
const anonDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE')
const anonDeleteAttempt = await send(`project/${existingProject.pid}`, 'DELETE')
expect(anonDeleteAttempt.status).toEqual(403)
expect(anonDeleteAttempt.body).toEqual(noPermError)
await login('testSu', 'test')
const fetchAfterAttempts = await send(`project/${createdProject.body.pid}`, 'GET')
expect(fetchAfterAttempts.status).toEqual(200)
expect(fetchAfterAttempts.body).toHaveProperty('pid', createdProject.body.pid)
const { body: afterAttemptsBody, status: afterAttemptsStatus } = await send<ProjectDetailsType>(
`project/${existingProject.pid}`,
'GET'
)
expect(afterAttemptsStatus).toEqual(200)
expect(afterAttemptsBody.pid).toEqual(existingProject.pid)
})
})
@@ -0,0 +1,147 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
import { login, logout, noPermError, resetDatabase, send } from '../utils'
import { pool } from '../../utils/db'
import {
editedProject,
existingPerson,
existingPerson2,
invalidMemberArrayError1,
invalidMemberArrayError2,
newProjectBasis,
} from './data'
import { ProjectDetailsType } from '../../../../frontend/src/shared/types'
import { ValidationError } from 'express-validator'
let existingProject: ProjectDetailsType | null = null
describe('Updating project works', () => {
beforeAll(async () => {
await resetDatabase()
await login()
const { body, status: getReqStat } = await send<ProjectDetailsType>('project/3', 'GET')
expect(getReqStat).toEqual(200)
existingProject = body
})
beforeEach(async () => {
await login()
})
afterAll(async () => {
await pool.end()
})
it('Request succeeds and returns valid number id', async () => {
const { body: resultBody, status: updateStatus } = await send<{ pid: number }>('project/', 'PUT', {
project: editedProject,
})
const { pid: updatedId } = resultBody
expect(typeof updatedId).toEqual('number')
expect(updateStatus).toEqual(200)
const { body, status: getReqStat } = await send<ProjectDetailsType>(`project/${updatedId}`, 'GET')
expect(getReqStat).toEqual(200)
existingProject = body
})
it('Contains correct data', () => {
const { proj_code, proj_name } = existingProject!
expect(proj_code).toEqual(editedProject.proj_code)
expect(proj_name).toEqual(editedProject.proj_name)
})
it('Updating fails with empty project code', async () => {
const { body: resultBody, status: getReqStatus } = await send('project/', 'PUT', {
project: { ...editedProject, proj_code: '' },
})
expect(getReqStatus).toEqual(403)
expect(resultBody.length).toEqual(1) //There should be 1 validation error
})
it('Updating fails for non-admin users', async () => {
await login('testEr')
const { body: resultBodyEr, status: resultStatusEr } = await send<{ pid: number }>('project/', 'PUT', {
project: { ...editedProject, proj_code: 'CODE2' },
})
expect(resultBodyEr).toEqual(noPermError)
expect(resultStatusEr).toEqual(403)
await login('testEu')
const { body: resultBodyEu, status: resultStatusEu } = await send<{ pid: number }>('project/', 'PUT', {
project: { ...editedProject, proj_code: 'CODE3' },
})
expect(resultBodyEu).toEqual(noPermError)
expect(resultStatusEu).toEqual(403)
logout()
const { body: resultBodyAnon, status: resultStatusAnon } = await send('project/', 'PUT', {
project: { ...editedProject, proj_code: 'CODE4' },
})
expect(resultBodyAnon).toEqual(noPermError)
expect(resultStatusAnon).toEqual(403)
})
it('Adding members works', async () => {
const { status: putResultStatus } = await send<ProjectDetailsType>('project/', 'PUT', {
project: {
...editedProject,
now_proj_people: [
...existingProject!.now_proj_people,
{ initials: existingPerson2.initials, pid: 3, com_people: existingPerson2 },
],
},
})
expect(putResultStatus).toEqual(200)
const { body: getResultBody, status: getResultStatus } = await send<ProjectDetailsType>('project/3', 'GET')
expect(getResultStatus).toEqual(200)
expect(getResultBody.now_proj_people).toHaveLength(2)
expect(getResultBody.now_proj_people[0].initials).toEqual(existingPerson.initials)
expect(getResultBody.now_proj_people[1].initials).toEqual(existingPerson2.initials)
existingProject = getResultBody
})
it('Removing members works', async () => {
expect(existingProject!.now_proj_people).toHaveLength(2) // set in previous test
const { status: putResultStatus } = await send<ProjectDetailsType>('project/', 'PUT', {
project: { ...editedProject, now_proj_people: newProjectBasis.now_proj_people },
})
expect(putResultStatus).toEqual(200)
const { body: getResultBody, status: getResultStatus } = await send<ProjectDetailsType>('project/3', 'GET')
expect(getResultStatus).toEqual(200)
expect(getResultBody.now_proj_people).toHaveLength(1)
expect(getResultBody.now_proj_people[0].initials).toEqual(existingPerson.initials)
const { status: putResultStatus2 } = await send<ProjectDetailsType>('project/', 'PUT', {
project: { ...editedProject, now_proj_people: [] },
})
expect(putResultStatus2).toEqual(200)
const { body: getResultBody2, status: getResultStatus2 } = await send<ProjectDetailsType>('project/3', 'GET')
expect(getResultStatus2).toEqual(200)
expect(getResultBody2.now_proj_people).toEqual([])
})
it('Adding members without required fields does not work', async () => {
const { body: putResultBody, status: putResultStatus } = await send('project/', 'PUT', {
project: {
...editedProject,
now_proj_people: [{ initials: existingPerson2.initials }],
},
})
expect(putResultStatus).toEqual(403)
expect(putResultBody).toHaveLength(1)
expect((putResultBody as Array<ValidationError>)[0]).toEqual(invalidMemberArrayError1)
const { body: putResultBody2, status: putResultStatus2 } = await send('project/', 'PUT', {
project: {
...editedProject,
now_proj_people: [{ com_people: existingPerson2 }],
},
})
expect(putResultStatus2).toEqual(403)
expect(putResultBody2).toHaveLength(1)
expect((putResultBody2 as Array<ValidationError>)[0]).toEqual(invalidMemberArrayError2)
})
})
@@ -1,88 +0,0 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { pool } from '../../utils/db'
describe('POST /projects', () => {
beforeAll(async () => {
await resetDatabase()
}, resetDatabaseTimeout)
afterAll(async () => {
await pool.end()
})
it('creates a project with members', async () => {
await login('testSu', 'test')
const result = await send<{ pid: number; contact: string; now_proj_people: Array<{ initials: string }> }>(
'projects',
'POST',
{
projectCode: 'PRJ-001',
projectName: 'Integration Test Project',
coordinatorUserId: 163,
projectStatus: 'current',
recordStatus: true,
memberUserIds: [167],
}
)
expect(result.status).toEqual(201)
expect(result.body.contact).toEqual('TEST-SU')
expect(result.body.now_proj_people).toEqual([{ initials: 'TEST-PL', pid: result.body.pid }])
})
it('returns validation error when coordinator is missing', async () => {
await login('testSu', 'test')
const result = await send('projects', 'POST', {
projectCode: 'PRJ-002',
projectName: 'Missing Coordinator',
projectStatus: 'planned',
recordStatus: false,
})
expect(result.status).toEqual(400)
})
it('rejects unknown member ids', async () => {
await login('testSu', 'test')
const result = await send('projects', 'POST', {
projectCode: 'PRJ-003',
projectName: 'Unknown Member',
coordinatorUserId: 163,
memberUserIds: [99999],
})
expect(result.status).toEqual(400)
})
it('denies non-admin users', async () => {
const unauthorizedProject = {
projectCode: 'PRJ-004',
projectName: 'Unauthorized Create',
coordinatorUserId: 163,
}
await login('testPl', 'test')
const plResult = await send('projects', 'POST', unauthorizedProject)
expect(plResult.status).toEqual(403)
expect(plResult.body).toEqual(noPermError)
await login('testEu', 'test')
const euResult = await send('projects', 'POST', unauthorizedProject)
expect(euResult.status).toEqual(403)
expect(euResult.body).toEqual(noPermError)
await login('testEr', 'test')
const erResult = await send('projects', 'POST', unauthorizedProject)
expect(erResult.status).toEqual(403)
expect(erResult.body).toEqual(noPermError)
logout()
const anonResult = await send('projects', 'POST', unauthorizedProject)
expect(anonResult.status).toEqual(403)
expect(anonResult.body).toEqual(noPermError)
})
})
@@ -1,102 +0,0 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { pool } from '../../utils/db'
describe('PUT /projects/:id', () => {
beforeAll(async () => {
await resetDatabase()
}, resetDatabaseTimeout)
beforeEach(async () => {
await resetDatabase()
await login('testSu', 'test')
})
afterAll(async () => {
await pool.end()
})
it('updates coordinator and members', async () => {
const createResult = await send<{ pid: number }>('projects', 'POST', {
projectCode: 'PRJ-UPDATE',
projectName: 'Project To Update',
coordinatorUserId: 163,
memberUserIds: [167],
})
expect(createResult.status).toEqual(201)
const result = await send<{
pid: number
contact: string
now_proj_people: Array<{ initials: string; pid: number }>
proj_name: string
proj_code: string
}>(`projects/${createResult.body.pid}`, 'PUT', {
projectCode: 'PRJ-UPD01',
projectName: 'Updated Project',
coordinatorUserId: 167,
memberUserIds: [163],
})
expect(result.status).toEqual(200)
expect(result.body.contact).toEqual('TEST-PL')
expect(result.body.proj_code).toEqual('PRJ-UPD01')
expect(result.body.proj_name).toEqual('Updated Project')
expect(result.body.now_proj_people).toEqual([{ initials: 'TEST-SU', pid: result.body.pid }])
})
it('returns validation error for invalid members', async () => {
const createResult = await send<{ pid: number }>('projects', 'POST', {
projectCode: 'PRJ-INV1',
projectName: 'Project Invalid Members',
coordinatorUserId: 163,
})
expect(createResult.status).toEqual(201)
const result = await send(`projects/${createResult.body.pid}`, 'PUT', {
memberUserIds: ['not-a-number'],
})
expect(result.status).toEqual(400)
})
it('returns validation error for too-long project code', async () => {
const createResult = await send<{ pid: number }>('projects', 'POST', {
projectCode: 'PRJ-LONG1',
projectName: 'Project Long Code',
coordinatorUserId: 163,
})
expect(createResult.status).toEqual(201)
const result = await send(`projects/${createResult.body.pid}`, 'PUT', {
projectCode: 'PRJ-UPDATED',
})
expect(result.status).toEqual(400)
expect(result.body).toEqual({ message: 'Project code must be at most 10 characters' })
})
it('denies non-admin users', async () => {
const unauthorizedUpdate = { projectName: 'Unauthorized Update' }
await login('testPl', 'test')
const plResult = await send('projects/1', 'PUT', unauthorizedUpdate)
expect(plResult.status).toEqual(403)
expect(plResult.body).toEqual(noPermError)
await login('testEu', 'test')
const euResult = await send('projects/1', 'PUT', unauthorizedUpdate)
expect(euResult.status).toEqual(403)
expect(euResult.body).toEqual(noPermError)
await login('testEr', 'test')
const erResult = await send('projects/1', 'PUT', unauthorizedUpdate)
expect(erResult.status).toEqual(403)
expect(erResult.body).toEqual(noPermError)
logout()
const anonResult = await send('projects/1', 'PUT', unauthorizedUpdate)
expect(anonResult.status).toEqual(403)
expect(anonResult.body).toEqual(noPermError)
})
})
+6 -3
View File
@@ -21,9 +21,12 @@ export const send = async <T extends Record<string, unknown> | Array<Record<stri
.set('Content-Type', 'application/json')
.set('authorization', `bearer ${token ?? ''}`)
// TODO remove this when fixBigInt is refactored. Also lint-ignore is then not needed
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
response.body = response.text ? JSON.parse(response.text) : response.body
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
response.body = response.text ? JSON.parse(response.text) : response.body
} catch {
response.body = response.text
}
break
case 'POST':
-2
View File
@@ -11,7 +11,6 @@ import collectingMethodValuesRouter from './routes/collectingMethodValues'
import regionRouter from './routes/region'
import personRouter from './routes/person'
import projectRouter from './routes/project'
import projectsRouter from './routes/projects'
import speciesRouter from './routes/species'
import localitySpeciesRouter from './routes/localitySpecies'
import refreshTokenRouter from './routes/refresh'
@@ -72,7 +71,6 @@ app.use('/time-bound', requireOneOf([Role.Admin, Role.EditUnrestricted]), timeBo
app.use('/region', requireOneOf([Role.Admin]), regionRouter)
app.use('/person', personRouter)
app.use('/project', projectRouter)
app.use('/projects', requireOneOf([Role.Admin]), projectsRouter)
app.use('/museum', museumRouter)
app.use('/sedimentary-structure', sedimentaryStructureRouter)
app.use('/collecting-method-values', collectingMethodValuesRouter)
@@ -1,14 +0,0 @@
import { Request, Response } from 'express'
import { createProject, CreateProjectInput, updateProject, UpdateProjectInput } from '../services/projectsService'
export const createProjectHandler = async (req: Request<object, object, CreateProjectInput>, res: Response) => {
const createdProject = await createProject(req.body)
return res.status(201).json(createdProject)
}
export const updateProjectHandler = async (req: Request<{ id: string }, object, UpdateProjectInput>, res: Response) => {
const updatedProject = await updateProject(Number(req.params.id), req.body)
return res.status(200).json(updatedProject)
}
+22 -3
View File
@@ -1,7 +1,8 @@
import { Router } from 'express'
import { deleteProject, getAllProjects, getProjectDetails } from '../services/project'
import { Request, Router } from 'express'
import { deleteProject, getAllProjects, getProjectDetails, validateEntireProject } from '../services/project'
import { requireOneOf } from '../middlewares/authorizer'
import { Role } from '../../../frontend/src/shared/types'
import { EditDataType, EditMetaData, ProjectDetailsType, Role } from '../../../frontend/src/shared/types'
import { writeProject } from '../services/write/project'
const router = Router()
@@ -17,6 +18,24 @@ router.get('/:id', requireOneOf([Role.Admin]), async (req, res) => {
return res.status(200).send(project)
})
router.put(
'/',
requireOneOf([Role.Admin]),
async (req: Request<object, object, { project: EditDataType<ProjectDetailsType> & EditMetaData }>, res) => {
try {
const { ...editedProject } = req.body.project
const validationErrors = validateEntireProject({ ...editedProject })
if (validationErrors.length > 0) {
return res.status(403).send(validationErrors)
}
const pid = await writeProject(editedProject)
return res.status(200).send({ pid })
} catch (error) {
return res.status(500).send({ message: 'Failed to write project' })
}
}
)
router.delete('/:id', requireOneOf([Role.Admin]), async (req, res) => {
const id = parseInt(req.params.id)
const deleted = await deleteProject(id)
-11
View File
@@ -1,11 +0,0 @@
import { Router } from 'express'
import { createProjectHandler, updateProjectHandler } from '../controllers/projectsController'
import { requireOneOf } from '../middlewares/authorizer'
import { Role } from '../../../frontend/src/shared/types'
const router = Router()
router.post('/', requireOneOf([Role.Admin]), createProjectHandler)
router.put('/:id', requireOneOf([Role.Admin]), updateProjectHandler)
export default router
+8 -2
View File
@@ -1,4 +1,6 @@
import { Role, User } from '../../../frontend/src/shared/types'
import { EditDataType, EditMetaData, ProjectDetailsType, Role, User } from '../../../frontend/src/shared/types'
import { validateProjectFields } from '../../../frontend/src/shared/validators/project'
import { ValidationObject } from '../../../frontend/src/shared/validators/validator'
import { nowDb } from '../utils/db'
export const getAllProjects = async (user?: User) => {
@@ -20,7 +22,6 @@ export const getAllProjects = async (user?: User) => {
export const getProjectDetails = async (id: number) => {
// TODO: Check if user has access
const result = await nowDb.now_proj.findUnique({
where: { pid: id },
include: {
@@ -61,3 +62,8 @@ export const deleteProject = async (projectId: number) => {
return true
}
export const validateEntireProject = (editedFields: EditDataType<ProjectDetailsType> & EditMetaData) => {
const errors: ValidationObject[] = validateProjectFields(editedFields)
return errors
}
-154
View File
@@ -1,154 +0,0 @@
import { type ProjectStatus } from '../constants/status'
import { nowDb } from '../utils/db'
import {
ensureNonEmptyString,
ensureValidMemberIds,
ensureValidProjectStatus,
ensureValidRecordStatus,
ensureValidUserId,
NotFoundError,
ValidationError,
} from '../validators/projectsValidator'
export type CreateProjectInput = {
projectCode: string
projectName: string
coordinatorUserId: number
projectStatus?: ProjectStatus | null
recordStatus?: boolean | null
memberUserIds?: number[]
}
export type UpdateProjectInput = {
projectCode?: string
projectName?: string
coordinatorUserId?: number
projectStatus?: ProjectStatus | null
recordStatus?: boolean | null
memberUserIds?: number[]
}
const ensureValidProjectStatusValue = (value: unknown) => ensureValidProjectStatus(value)
const ensureValidRecordStatusValue = (value: unknown) => ensureValidRecordStatus(value)
const loadMembersByIds = async (memberIds: number[]) => {
if (!memberIds.length) return [] as Array<{ initials: string; user_id: number }>
const members = await nowDb.com_people.findMany({
select: { initials: true, user_id: true },
where: { user_id: { in: memberIds } },
})
if (members.length !== memberIds.length) {
throw new ValidationError('One or more project members do not exist')
}
return members
}
const loadCoordinatorById = async (coordinatorId: number) => {
const coordinator = await nowDb.com_people.findFirst({
select: { initials: true },
where: { user_id: coordinatorId },
})
if (!coordinator) {
throw new ValidationError('Coordinator user does not exist')
}
return coordinator
}
export const createProject = async ({
projectCode,
projectName,
coordinatorUserId,
projectStatus,
recordStatus,
memberUserIds,
}: CreateProjectInput) => {
const proj_code = ensureNonEmptyString(projectCode, 'Project code', 10)
const proj_name = ensureNonEmptyString(projectName, 'Project name', 80)
const coordinatorId = ensureValidUserId(coordinatorUserId, 'Coordinator user ID')
const uniqueMemberIds = ensureValidMemberIds(memberUserIds)
const validProjectStatus = ensureValidProjectStatusValue(projectStatus)
const validRecordStatus = ensureValidRecordStatusValue(recordStatus)
const coordinator = await loadCoordinatorById(coordinatorId)
const members = await loadMembersByIds(uniqueMemberIds)
const project = await nowDb.$transaction(async prisma => {
const createdProject = await prisma.now_proj.create({
data: {
proj_code,
proj_name,
proj_status: validProjectStatus,
proj_records: validRecordStatus,
contact: coordinator.initials,
now_proj_people: members.length
? {
create: members.map(member => ({ initials: member.initials })),
}
: undefined,
},
include: { now_proj_people: true },
})
return createdProject
})
return project
}
export const updateProject = async (
projectId: number,
{ projectCode, projectName, coordinatorUserId, projectStatus, recordStatus, memberUserIds }: UpdateProjectInput
) => {
const existingProject = await nowDb.now_proj.findUnique({
where: { pid: projectId },
include: { now_proj_people: true },
})
if (!existingProject) {
throw new NotFoundError('Project not found')
}
const proj_code = projectCode ? ensureNonEmptyString(projectCode, 'Project code', 10) : existingProject.proj_code
const proj_name = projectName ? ensureNonEmptyString(projectName, 'Project name', 80) : existingProject.proj_name
const validProjectStatus = ensureValidProjectStatusValue(projectStatus ?? existingProject.proj_status)
const validRecordStatus = ensureValidRecordStatusValue(recordStatus ?? existingProject.proj_records)
const coordinatorInitials =
coordinatorUserId !== undefined
? (await loadCoordinatorById(ensureValidUserId(coordinatorUserId, 'Coordinator user ID'))).initials
: existingProject.contact
const memberIdsToUpdate = memberUserIds !== undefined ? ensureValidMemberIds(memberUserIds) : null
const members = memberIdsToUpdate ? await loadMembersByIds(memberIdsToUpdate) : null
const project = await nowDb.$transaction(async prisma => {
const updatedProject = await prisma.now_proj.update({
where: { pid: projectId },
data: {
proj_code,
proj_name,
proj_status: validProjectStatus,
proj_records: validRecordStatus,
contact: coordinatorInitials,
},
})
if (members !== null && memberIdsToUpdate !== null) {
await prisma.now_proj_people.deleteMany({ where: { pid: projectId } })
if (members.length) {
await prisma.now_proj_people.createMany({
data: members.map(member => ({ pid: projectId, initials: member.initials })),
})
}
}
return prisma.now_proj.findUnique({ where: { pid: updatedProject.pid }, include: { now_proj_people: true } })
})
return project
}
+79
View File
@@ -0,0 +1,79 @@
import { EditDataType, ProjectDetailsType } from '../../../../frontend/src/shared/types'
import Prisma from '../../../prisma/generated/now_test_client'
import { getFieldsOfTables, nowDb } from '../../utils/db'
import { getProjectDetails } from '../project'
import { filterAllowedKeys, fixRadioSelection } from './writeOperations/utils'
const loadMembersByIds = async (memberIds: number[]) => {
if (!memberIds.length) return [] as Array<{ initials: string; user_id: number }>
const members = await nowDb.com_people.findMany({
select: { initials: true, user_id: true },
where: { user_id: { in: memberIds } },
})
if (members.length !== memberIds.length) {
throw new Error('One or more project members do not exist')
}
return members
}
export const writeProject = async (project: EditDataType<ProjectDetailsType>) => {
const allowedColumns = getFieldsOfTables(['now_proj'])
const filteredProject = filterAllowedKeys(project, allowedColumns) as Prisma.now_proj
let projectId: number
const contactPerson = await nowDb.com_people.findFirst({ where: { initials: project.contact } })
if (!contactPerson) {
throw new Error('Contact does not exist')
}
const memberUserIds = project.now_proj_people
.filter(member => member.rowState !== 'removed')
.map(member => member.com_people!.user_id!)
const members = await loadMembersByIds(memberUserIds)
const existingProject = filteredProject.pid ? await getProjectDetails(filteredProject.pid) : null
if (!existingProject) {
const newProject = await nowDb.now_proj.create({
data: {
...filteredProject,
proj_records: fixRadioSelection(filteredProject.proj_records),
now_proj_people:
members.length > 0
? {
create: members.map(member => ({ initials: member.initials })),
}
: undefined,
},
include: { now_proj_people: true },
})
projectId = newProject.pid
} else {
await nowDb.$transaction(async prisma => {
await prisma.now_proj.update({
where: { pid: filteredProject.pid },
data: {
...filteredProject,
proj_records: fixRadioSelection(filteredProject.proj_records),
},
})
if (existingProject) {
// removes all members, then adds back ones that are not marked as removed
if (Array.isArray(members)) {
await prisma.now_proj_people.deleteMany({ where: { pid: filteredProject.pid } })
if (members.length > 0) {
await prisma.now_proj_people.createMany({
data: members.map(member => ({ pid: filteredProject.pid, initials: member.initials })),
})
}
}
}
})
projectId = filteredProject.pid
}
return projectId
}
@@ -1,24 +0,0 @@
import { describe, expect, it } from '@jest/globals'
import { ensureValidMemberIds, ensureValidUserId, ValidationError } from '../../validators/projectsValidator'
describe('projectsValidator', () => {
it('validates numeric coordinator id', () => {
expect(ensureValidUserId(5, 'Coordinator user ID')).toEqual(5)
})
it('throws on invalid coordinator id', () => {
expect(() => ensureValidUserId('abc', 'Coordinator user ID')).toThrow(ValidationError)
})
it('accepts unique numeric member ids', () => {
expect(ensureValidMemberIds([1, 1, 2])).toEqual([1, 2])
})
it('rejects non-array member ids', () => {
expect(() => ensureValidMemberIds('1,2,3')).toThrow(ValidationError)
})
it('rejects member ids containing non-numbers', () => {
expect(() => ensureValidMemberIds([1, 'two', 3])).toThrow(ValidationError)
})
})
@@ -1,75 +0,0 @@
import { PROJECT_STATUSES, type ProjectStatus, RECORD_STATUS_VALUES } from '../constants/status'
export class ValidationError extends Error {
declare status: number
constructor(message: string) {
super(message)
this.status = 400
}
}
export class NotFoundError extends Error {
declare status: number
constructor(message: string) {
super(message)
this.status = 404
}
}
export const ensureNonEmptyString = (value: unknown, fieldName: string, maxLength?: number) => {
if (typeof value !== 'string' || value.trim() === '') {
throw new ValidationError(`${fieldName} is required`)
}
const trimmedValue = value.trim()
if (maxLength !== undefined && trimmedValue.length > maxLength) {
throw new ValidationError(`${fieldName} must be at most ${maxLength} characters`)
}
return trimmedValue
}
export const ensureValidUserId = (value: unknown, fieldName: string) => {
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new ValidationError(`${fieldName} must be a valid number`)
}
return value
}
export const ensureValidMemberIds = (memberUserIds: unknown) => {
if (memberUserIds === undefined) return [] as number[]
if (!Array.isArray(memberUserIds)) {
throw new ValidationError('Project members must be an array of user IDs')
}
const numericIds = memberUserIds.filter((id): id is number => typeof id === 'number' && Number.isInteger(id))
if (numericIds.length !== memberUserIds.length) {
throw new ValidationError('Project members must be an array of user IDs')
}
return Array.from(new Set(numericIds))
}
export const ensureValidProjectStatus = (value: unknown): ProjectStatus | null => {
if (value === undefined || value === null || value === '') return null
if (typeof value !== 'string' || !PROJECT_STATUSES.includes(value as ProjectStatus)) {
throw new ValidationError('Project status is invalid')
}
return value as ProjectStatus
}
export const ensureValidRecordStatus = (value: unknown): boolean | null => {
if (value === undefined || value === null || value === '') return null
if (typeof value !== 'boolean' || !RECORD_STATUS_VALUES.includes(value)) {
throw new ValidationError('Record status is invalid')
}
return value
}