Add protected person deletion

This commit is contained in:
karilint
2026-05-25 14:04:06 +03:00
parent ee1421a5bd
commit 6f4428211c
6 changed files with 359 additions and 4 deletions
+100
View File
@@ -0,0 +1,100 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
import { pool, nowDb } from '../../utils/db'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
const createPerson = async (initials: string, userId?: number) => {
await nowDb.com_people.create({
data: {
initials,
first_name: 'Delete',
surname: 'Candidate',
full_name: 'Delete Candidate',
email: `${initials.toLowerCase()}@example.com`,
organization: 'Test organization',
country: 'Finland',
user_id: userId,
},
})
}
describe('Deleting person works', () => {
beforeAll(async () => {
await resetDatabase()
}, resetDatabaseTimeout)
beforeEach(async () => {
await login()
})
afterAll(async () => {
await pool.end()
})
it('deletes a person without user relation or protected activity', async () => {
await createPerson('DEL-NO')
const deleteResult = await send('person/DEL-NO', 'DELETE')
expect(deleteResult.status).toEqual(200)
const deletedPerson = await nowDb.com_people.findUnique({ where: { initials: 'DEL-NO' } })
expect(deletedPerson).toBeNull()
})
it('deletes a person and their user when the user has no protected activity', async () => {
const user = await nowDb.com_users.create({
data: {
user_name: 'delete-person-user',
now_user_group: 'eu',
},
})
await createPerson('DEL-USR', user.user_id)
const deleteResult = await send('person/DEL-USR', 'DELETE')
expect(deleteResult.status).toEqual(200)
const deletedPerson = await nowDb.com_people.findUnique({ where: { initials: 'DEL-USR' } })
const deletedUser = await nowDb.com_users.findUnique({ where: { user_id: user.user_id } })
expect(deletedPerson).toBeNull()
expect(deletedUser).toBeNull()
})
it('does not delete a person with update history or project and coordinator links', async () => {
const deleteResult = await send<{ blockers: string[]; message: string }>('person/AD', 'DELETE')
expect(deleteResult.status).toEqual(409)
expect(deleteResult.body.message).toEqual(
'Person cannot be deleted because they are still linked to database data.'
)
expect(deleteResult.body.blockers).toEqual([
'person has data update history',
'person is assigned to projects or coordinator groups',
])
const person = await nowDb.com_people.findUnique({ where: { initials: 'AD' } })
expect(person).not.toBeNull()
})
it('does not allow users to delete their own person record', async () => {
const deleteResult = await send<{ blockers: string[]; message: string }>('person/TEST-SU', 'DELETE')
expect(deleteResult.status).toEqual(409)
expect(deleteResult.body.message).toEqual('You cannot delete your own person record.')
expect(deleteResult.body.blockers).toEqual(['self'])
})
it('deleting fails without admin permissions', async () => {
await createPerson('DEL-PERM')
logout()
const deleteResultNoPerm = await send('person/DEL-PERM', 'DELETE')
expect(deleteResultNoPerm.status).toEqual(403)
await login('testEr')
const deleteResultEr = await send('person/DEL-PERM', 'DELETE')
expect(deleteResultEr.status).toEqual(403)
expect(deleteResultEr.body).toEqual(noPermError)
const person = await nowDb.com_people.findUnique({ where: { initials: 'DEL-PERM' } })
expect(person).not.toBeNull()
})
})
+11 -1
View File
@@ -1,5 +1,5 @@
import { Router, Request } from 'express'
import { getAllPersons, getPersonDetails, validateEntirePerson } from '../services/person'
import { deletePerson, getAllPersons, getPersonDetails, validateEntirePerson } from '../services/person'
import {
Role,
PersonDetailsType,
@@ -73,4 +73,14 @@ router.put(
}
)
router.delete('/:id', requireOneOf([Role.Admin]), async (req, res) => {
const result = await deletePerson(req.params.id, req.user!.initials)
if (!result.deleted) {
return res.status(result.status).send({ message: result.message, blockers: result.blockers })
}
return res.status(200).send()
})
export default router
+72
View File
@@ -4,6 +4,9 @@ import Prisma from '../../prisma/generated/now_test_client'
import { ValidationObject } from '../../../frontend/src/shared/validators/validator'
import { validatePerson } from '../../../frontend/src/shared/validators/person'
export const PERSON_DELETE_BLOCKED_MESSAGE = 'Person cannot be deleted because they are still linked to database data.'
export const PERSON_DELETE_SELF_MESSAGE = 'You cannot delete your own person record.'
export const getAllPersons = async () => {
const persons = await nowDb.com_people.findMany({})
const users = await nowDb.com_users.findMany({})
@@ -39,3 +42,72 @@ export const validateEntirePerson = (editedFields: EditDataType<Prisma.com_peopl
}
return errors
}
const hasAny = async (checks: Array<Promise<number>>): Promise<boolean> => {
const counts = await Promise.all(checks)
return counts.some(count => count > 0)
}
const hasUpdateActivity = async (initials: string): Promise<boolean> => {
return hasAny([
nowDb.now_lau.count({ where: { OR: [{ lau_coordinator: initials }, { lau_authorizer: initials }] } }),
nowDb.now_sau.count({ where: { OR: [{ sau_coordinator: initials }, { sau_authorizer: initials }] } }),
nowDb.now_tau.count({ where: { OR: [{ tau_coordinator: initials }, { tau_authorizer: initials }] } }),
nowDb.now_bau.count({ where: { OR: [{ bau_coordinator: initials }, { bau_authorizer: initials }] } }),
nowDb.now_time_update.count({ where: { OR: [{ coordinator: initials }, { authorizer: initials }] } }),
])
}
const hasCoordinationOrProjectLinks = async (initials: string): Promise<boolean> => {
return hasAny([
nowDb.now_proj.count({ where: { contact: initials } }),
nowDb.now_proj_people.count({ where: { initials } }),
nowDb.now_reg_coord_people.count({ where: { initials } }),
nowDb.now_sp_coord_people.count({ where: { initials } }),
nowDb.now_strat_coord_people.count({ where: { initials } }),
])
}
export const getPersonDeleteBlockers = async (initials: string): Promise<string[]> => {
const blockers: string[] = []
if (await hasUpdateActivity(initials)) {
blockers.push('person has data update history')
}
if (await hasCoordinationOrProjectLinks(initials)) {
blockers.push('person is assigned to projects or coordinator groups')
}
return blockers
}
export const deletePerson = async (initials: string, currentUserInitials: string) => {
if (initials === currentUserInitials) {
return { deleted: false, status: 409 as const, message: PERSON_DELETE_SELF_MESSAGE, blockers: ['self'] }
}
const person = await nowDb.com_people.findUnique({
where: { initials },
select: { initials: true, user_id: true },
})
if (!person) {
return { deleted: false, status: 404 as const, message: 'Person not found.', blockers: [] }
}
const blockers = await getPersonDeleteBlockers(initials)
if (blockers.length > 0) {
return { deleted: false, status: 409 as const, message: PERSON_DELETE_BLOCKED_MESSAGE, blockers }
}
await nowDb.$transaction(async prisma => {
await prisma.com_people.delete({ where: { initials } })
if (person.user_id) {
await prisma.com_users.deleteMany({ where: { user_id: person.user_id } })
}
})
return { deleted: true, status: 200 as const, message: null, blockers: [] }
}
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { PersonDetails } from '@/components/Person/PersonDetails'
import { PersonDetailsType, Role } from '@/shared/types'
jest.mock('@/components/DetailView/DetailView', () => ({
DetailView: (props: { deleteFunction?: () => Promise<void> }) => {
const handleDelete = () => {
if (props.deleteFunction) void props.deleteFunction()
}
return <button onClick={handleDelete}>Delete</button>
},
}))
jest.mock('@/components/Person/Tabs/PersonTab', () => ({
PersonTab: () => <div data-testid="person-tab" />,
}))
const mockUseGetPersonDetailsQuery = jest.fn()
const mockUseGetPersonDetailsIdMutation = jest.fn()
const mockUseEditPersonMutation = jest.fn()
const mockUseDeletePersonMutation = jest.fn()
const mockUseUser = jest.fn()
const mockNotify = jest.fn()
const mockNavigate = jest.fn()
let deletePersonMock: jest.Mock
jest.mock('@/redux/personReducer', () => ({
useGetPersonDetailsQuery: (id: string, options?: unknown) => mockUseGetPersonDetailsQuery(id, options),
useGetPersonDetailsIdMutation: () => mockUseGetPersonDetailsIdMutation(),
useEditPersonMutation: () => mockUseEditPersonMutation(),
useDeletePersonMutation: () => mockUseDeletePersonMutation(),
}))
jest.mock('@/hooks/user', () => ({
useUser: () => mockUseUser(),
}))
jest.mock('@/hooks/notification', () => ({
useNotify: () => ({ notify: mockNotify }),
}))
jest.mock('react-router-dom', () => {
const actualRouterDom = jest.requireActual<typeof import('react-router-dom')>('react-router-dom')
return {
...actualRouterDom,
useNavigate: () => mockNavigate,
}
})
const person: PersonDetailsType = {
initials: 'DEL-UI',
first_name: 'Delete',
surname: 'Ui',
full_name: 'Delete Ui',
format: null,
email: 'delete-ui@example.com',
user_id: null,
organization: 'Test organization',
country: 'Finland',
password_set: null,
used_morph: null,
used_now: null,
used_gene: null,
user: null,
now_user_group: '',
}
const renderPersonDetails = () => {
render(
<MemoryRouter initialEntries={['/person/DEL-UI']}>
<Routes>
<Route path="/person/:id" element={<PersonDetails />} />
</Routes>
</MemoryRouter>
)
}
describe('PersonDetails', () => {
beforeEach(() => {
mockUseUser.mockReturnValue({ initials: 'TEST-SU', role: Role.Admin })
mockUseGetPersonDetailsQuery.mockReturnValue({ data: person, isLoading: false, isError: false })
mockUseGetPersonDetailsIdMutation.mockReturnValue([jest.fn()])
mockUseEditPersonMutation.mockReturnValue([jest.fn(), { isLoading: false }])
deletePersonMock = jest.fn(() => ({ unwrap: () => Promise.resolve() }))
mockUseDeletePersonMutation.mockReturnValue([deletePersonMock, { isLoading: false }])
mockNotify.mockReset()
mockNavigate.mockReset()
})
it('deletes the person and navigates back to the list', async () => {
renderPersonDetails()
await userEvent.click(screen.getByRole('button', { name: /delete/i }))
await waitFor(() => {
expect(deletePersonMock).toHaveBeenCalledWith(person.initials)
})
expect(mockNotify).toHaveBeenCalledWith('Deleted person successfully.')
expect(mockNavigate).toHaveBeenCalledWith('/person')
})
it('shows the backend message when deleting is blocked', async () => {
deletePersonMock.mockReturnValue({
unwrap: () =>
Promise.reject(
Object.assign(new Error('Delete blocked'), {
status: 409,
data: { message: 'Person cannot be deleted because they are still linked to database data.' },
})
),
})
renderPersonDetails()
await userEvent.click(screen.getByRole('button', { name: /delete/i }))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
'Person cannot be deleted because they are still linked to database data.',
'error'
)
})
expect(mockNavigate).not.toHaveBeenCalled()
})
})
@@ -1,5 +1,6 @@
import { useParams, useNavigate } from 'react-router-dom'
import {
useDeletePersonMutation,
useEditPersonMutation,
useGetPersonDetailsIdMutation,
useGetPersonDetailsQuery,
@@ -13,11 +14,30 @@ import { validatePerson } from '@/shared/validators/person'
import { useNotify } from '@/hooks/notification'
import { useEffect } from 'react'
import { emptyPerson } from '../DetailView/common/defaultValues'
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
const getDeleteErrorMessage = (error: unknown): string => {
const fetchError = error as FetchBaseQueryError
if (
fetchError &&
typeof fetchError === 'object' &&
'data' in fetchError &&
fetchError.data &&
typeof fetchError.data === 'object' &&
'message' in fetchError.data &&
typeof fetchError.data.message === 'string'
) {
return fetchError.data.message
}
return 'Could not delete person.'
}
export const PersonDetails = () => {
const { id: idFromUrl } = useParams()
const user = useUser()
const [editPersonRequest, { isLoading: mutationLoading }] = useEditPersonMutation()
const [deletePersonRequest, { isLoading: deleteLoading }] = useDeletePersonMutation()
const { notify } = useNotify()
const navigate = useNavigate()
@@ -70,10 +90,22 @@ export const PersonDetails = () => {
}
if (isError) return <div>Error loading data</div>
if (isLoading || (!data && !isNew) || mutationLoading) return <CircularProgress />
if (isLoading || (!data && !isNew) || mutationLoading || deleteLoading) return <CircularProgress />
document.title = isNew ? 'New person' : `User - ${data!.user?.user_name}`
const deleteFunction = async () => {
if (!id) return
try {
await deletePersonRequest(id).unwrap()
notify('Deleted person successfully.')
navigate('/person')
} catch (error) {
notify(getDeleteErrorMessage(error), 'error')
}
}
const tabs: TabType[] = [
{
title: 'Person',
@@ -90,6 +122,7 @@ export const PersonDetails = () => {
tabs={tabs}
data={isNew ? emptyPerson : data!}
validator={validatePerson}
deleteFunction={deleteFunction}
/>
)
}
+14 -2
View File
@@ -28,8 +28,20 @@ const personsApi = api.injectEndpoints({
}),
invalidatesTags: (result, _error, { initials }) => (result ? [{ type: 'person', id: initials }, 'persons'] : []),
}),
deletePerson: builder.mutation<void, string>({
query: initials => ({
url: `/person/${initials}`,
method: 'DELETE',
}),
invalidatesTags: (_result, _error, initials) => [{ type: 'person', id: initials }, 'persons'],
}),
}),
})
export const { useGetAllPersonsQuery, useGetPersonDetailsQuery, useEditPersonMutation, useGetPersonDetailsIdMutation } =
personsApi
export const {
useGetAllPersonsQuery,
useGetPersonDetailsQuery,
useEditPersonMutation,
useGetPersonDetailsIdMutation,
useDeletePersonMutation,
} = personsApi