diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/get.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/get.ts index bbbf0f6ef2..4dccca0414 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/get.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/get.ts @@ -115,14 +115,19 @@ export function getOccurenceSubmission(): RequestHandler { getOccurrenceSubmissionSQLStatement.values ); - let messageList = []; - + // Ensure we only retrieve the latest occurrence submission record if it has not been soft deleted if ( - occurrenceSubmissionData && - occurrenceSubmissionData.rows && - occurrenceSubmissionData.rows[0] && - occurrenceSubmissionData.rows[0].submission_status_type_name === 'Rejected' + !occurrenceSubmissionData || + !occurrenceSubmissionData.rows || + !occurrenceSubmissionData.rows[0] || + occurrenceSubmissionData.rows[0].soft_delete_timestamp ) { + return res.status(200).json(null); + } + + let messageList = []; + + if (occurrenceSubmissionData.rows[0].submission_status_type_name === 'Rejected') { const occurrence_submission_id = occurrenceSubmissionData.rows[0].id; const getSubmissionErrorListSQLStatement = getOccurrenceSubmissionMessagesSQL(Number(occurrence_submission_id)); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/delete.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/delete.test.ts new file mode 100644 index 0000000000..c5e23f8c02 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/delete.test.ts @@ -0,0 +1,174 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as delete_submission from './delete'; +import * as db from '../../../../../../../../database/db'; +import * as survey_occurrence_queries from '../../../../../../../../queries/survey/survey-occurrence-queries'; +import SQL from 'sql-template-strings'; + +chai.use(sinonChai); + +describe('deleteOccurrenceSubmission', () => { + const dbConnectionObj = { + systemUserId: () => { + return null; + }, + open: async () => { + // do nothing + }, + release: () => { + // do nothing + }, + commit: async () => { + // do nothing + }, + rollback: async () => { + // do nothing + }, + query: async () => { + // do nothing + } + }; + + const sampleReq = { + keycloak_token: {}, + params: { + projectId: 1, + surveyId: 1, + submissionId: 1 + } + } as any; + + let actualResult: any = null; + + const sampleRes = { + status: () => { + return { + json: (result: any) => { + actualResult = result; + } + }; + } + }; + + afterEach(() => { + sinon.restore(); + }); + + it('should throw a 400 error when no projectId is provided', async () => { + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + try { + const result = delete_submission.deleteOccurrenceSubmission(); + await result( + { ...sampleReq, params: { ...sampleReq.params, projectId: null } }, + (null as unknown) as any, + (null as unknown) as any + ); + expect.fail(); + } catch (actualError) { + expect(actualError.status).to.equal(400); + expect(actualError.message).to.equal('Missing required path param `projectId`'); + } + }); + + it('should throw a 400 error when no surveyId is provided', async () => { + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + try { + const result = delete_submission.deleteOccurrenceSubmission(); + await result( + { ...sampleReq, params: { ...sampleReq.params, surveyId: null } }, + (null as unknown) as any, + (null as unknown) as any + ); + expect.fail(); + } catch (actualError) { + expect(actualError.status).to.equal(400); + expect(actualError.message).to.equal('Missing required path param `surveyId`'); + } + }); + + it('should throw a 400 error when no submissionId is provided', async () => { + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + try { + const result = delete_submission.deleteOccurrenceSubmission(); + await result( + { ...sampleReq, params: { ...sampleReq.params, submissionId: null } }, + (null as unknown) as any, + (null as unknown) as any + ); + expect.fail(); + } catch (actualError) { + expect(actualError.status).to.equal(400); + expect(actualError.message).to.equal('Missing required path param `submissionId`'); + } + }); + + it('should throw a 400 error when no sql statement returned for deleteOccurrenceSubmissionSQL', async () => { + sinon.stub(db, 'getDBConnection').returns({ + ...dbConnectionObj, + systemUserId: () => { + return 20; + } + }); + + sinon.stub(survey_occurrence_queries, 'deleteOccurrenceSubmissionSQL').returns(null); + + try { + const result = delete_submission.deleteOccurrenceSubmission(); + + await result(sampleReq, (null as unknown) as any, (null as unknown) as any); + expect.fail(); + } catch (actualError) { + expect(actualError.status).to.equal(400); + expect(actualError.message).to.equal('Failed to build SQL delete statement'); + } + }); + + it('should return null when no rowCount', async () => { + const mockQuery = sinon.stub(); + + mockQuery.resolves({ rowCount: null }); + + sinon.stub(db, 'getDBConnection').returns({ + ...dbConnectionObj, + systemUserId: () => { + return 20; + }, + query: mockQuery + }); + + sinon.stub(survey_occurrence_queries, 'deleteOccurrenceSubmissionSQL').returns(SQL`something`); + + const result = delete_submission.deleteOccurrenceSubmission(); + + await result(sampleReq, sampleRes as any, (null as unknown) as any); + + expect(actualResult).to.equal(null); + }); + + it('should return rowCount on success', async () => { + const mockQuery = sinon.stub(); + + mockQuery.resolves({ rowCount: 1 }); + + sinon.stub(db, 'getDBConnection').returns({ + ...dbConnectionObj, + systemUserId: () => { + return 20; + }, + query: mockQuery + }); + + sinon.stub(survey_occurrence_queries, 'deleteOccurrenceSubmissionSQL').returns(SQL`something`); + + const result = delete_submission.deleteOccurrenceSubmission(); + + await result(sampleReq, sampleRes as any, (null as unknown) as any); + + expect(actualResult).to.equal(1); + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/delete.ts new file mode 100644 index 0000000000..3c72e3e24a --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/delete.ts @@ -0,0 +1,128 @@ +'use strict'; + +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../../database/db'; +import { HTTP400 } from '../../../../../../../../errors/CustomError'; +import { deleteOccurrenceSubmissionSQL } from '../../../../../../../../queries/survey/survey-occurrence-queries'; +import { getLogger } from '../../../../../../../../utils/logger'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/delete'); + +export const DELETE: Operation = [deleteOccurrenceSubmission()]; + +DELETE.apiDoc = { + description: 'Soft deletes an occurrence submission by ID.', + tags: ['observation_submission', 'delete'], + security: [ + { + Bearer: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.PROJECT_ADMIN] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'number' + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'number' + }, + required: true + }, + { + in: 'path', + name: 'submissionId', + schema: { + type: 'number' + }, + required: true + } + ], + responses: { + 200: { + description: 'Observation submission csv details response.', + content: { + 'application/json': { + schema: { + title: 'Row count of soft deleted records', + type: 'number' + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function deleteOccurrenceSubmission(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ + label: 'Soft delete an occurrence submission by ID', + message: 'params', + req_params: req.params + }); + + if (!req.params.projectId) { + throw new HTTP400('Missing required path param `projectId`'); + } + + if (!req.params.surveyId) { + throw new HTTP400('Missing required path param `surveyId`'); + } + + if (!req.params.submissionId) { + throw new HTTP400('Missing required path param `submissionId`'); + } + + const connection = getDBConnection(req['keycloak_token']); + + try { + const deleteSubmissionSQLStatement = deleteOccurrenceSubmissionSQL(Number(req.params.submissionId)); + + if (!deleteSubmissionSQLStatement) { + throw new HTTP400('Failed to build SQL delete statement'); + } + + await connection.open(); + + const deleteResult = await connection.query( + deleteSubmissionSQLStatement.text, + deleteSubmissionSQLStatement.values + ); + + await connection.commit(); + + const deleteResponse = (deleteResult && deleteResult.rowCount) || null; + + return res.status(200).json(deleteResponse); + } catch (error) { + defaultLog.debug({ label: 'deleteOccurrenceSubmission', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/queries/survey/survey-occurrence-queries.test.ts b/api/src/queries/survey/survey-occurrence-queries.test.ts index 6066e19f92..a9d2111f44 100644 --- a/api/src/queries/survey/survey-occurrence-queries.test.ts +++ b/api/src/queries/survey/survey-occurrence-queries.test.ts @@ -8,7 +8,8 @@ import { insertSurveyOccurrenceSubmissionSQL, insertOccurrenceSubmissionMessageSQL, insertOccurrenceSubmissionStatusSQL, - updateSurveyOccurrenceSubmissionWithKeySQL + updateSurveyOccurrenceSubmissionWithKeySQL, + deleteOccurrenceSubmissionSQL } from './survey-occurrence-queries'; describe('insertSurveyOccurrenceSubmissionSQL', () => { @@ -37,6 +38,20 @@ describe('insertSurveyOccurrenceSubmissionSQL', () => { }); }); +describe('deleteOccurrenceSubmissionSQL', () => { + it('returns null response when null submissionId provided', () => { + const response = deleteOccurrenceSubmissionSQL((null as unknown) as number); + + expect(response).to.be.null; + }); + + it('returns non null response when valid params provided', () => { + const response = deleteOccurrenceSubmissionSQL(1); + + expect(response).to.not.be.null; + }); +}); + describe('getLatestSurveyOccurrenceSubmission', () => { it('returns null response when null surveyId provided', () => { const response = getLatestSurveyOccurrenceSubmissionSQL((null as unknown) as number); diff --git a/api/src/queries/survey/survey-occurrence-queries.ts b/api/src/queries/survey/survey-occurrence-queries.ts index 1d27a4533f..616eec33d8 100644 --- a/api/src/queries/survey/survey-occurrence-queries.ts +++ b/api/src/queries/survey/survey-occurrence-queries.ts @@ -114,6 +114,7 @@ export const getLatestSurveyOccurrenceSubmissionSQL = (surveyId: number): SQLSta os.occurrence_submission_id as id, os.survey_id, os.source, + os.soft_delete_timestamp, os.event_timestamp, os.key, os.file_name, @@ -226,6 +227,39 @@ export const getSurveyOccurrenceSubmissionSQL = (occurrenceSubmissionId: number) return sqlStatement; }; +/** + * SQL query to soft delete the occurrence submission entry by ID + * + * @param {number} occurrenceSubmissionId + * @returns {SQLStatement} sql query object + */ +export const deleteOccurrenceSubmissionSQL = (occurrenceSubmissionId: number): SQLStatement | null => { + defaultLog.debug({ + label: 'deleteOccurrenceSubmissionSQL', + message: 'params', + occurrenceSubmissionId + }); + + if (!occurrenceSubmissionId) { + return null; + } + + const sqlStatement: SQLStatement = SQL` + UPDATE occurrence_submission + SET soft_delete_timestamp = now() + WHERE occurrence_submission_id = ${occurrenceSubmissionId}; + `; + + defaultLog.debug({ + label: 'deleteOccurrenceSubmissionSQL', + message: 'sql', + 'sqlStatement.text': sqlStatement.text, + 'sqlStatement.values': sqlStatement.values + }); + + return sqlStatement; +}; + /** * SQL query to insert the occurrence submission status. * diff --git a/app/src/features/surveys/view/SurveyObservations.tsx b/app/src/features/surveys/view/SurveyObservations.tsx index 6977c00541..b906003181 100644 --- a/app/src/features/surveys/view/SurveyObservations.tsx +++ b/app/src/features/surveys/view/SurveyObservations.tsx @@ -1,6 +1,7 @@ import Box from '@material-ui/core/Box'; import Button from '@material-ui/core/Button'; import CircularProgress from '@material-ui/core/CircularProgress'; +import IconButton from '@material-ui/core/IconButton'; import Link from '@material-ui/core/Link'; import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; @@ -9,7 +10,7 @@ import makeStyles from '@material-ui/core/styles/makeStyles'; import Typography from '@material-ui/core/Typography'; import Alert from '@material-ui/lab/Alert'; import AlertTitle from '@material-ui/lab/AlertTitle'; -import { mdiClockOutline, mdiFileOutline, mdiImport } from '@mdi/js'; +import { mdiClockOutline, mdiFileOutline, mdiImport, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; import FileUpload from 'components/attachments/FileUpload'; import { IUploadHandler } from 'components/attachments/FileUploadItem'; @@ -18,7 +19,7 @@ import { DialogContext } from 'contexts/dialogContext'; import ObservationSubmissionCSV from 'features/observations/components/ObservationSubmissionCSV'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useInterval } from 'hooks/useInterval'; -import React, { useContext, useEffect, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; import { useParams } from 'react-router'; const useStyles = makeStyles(() => ({ @@ -44,13 +45,13 @@ const useStyles = makeStyles(() => ({ } })); -const SurveyObservations: React.FC = () => { +const SurveyObservations = () => { const biohubApi = useBiohubApi(); const urlParams = useParams(); const projectId = urlParams['id']; const surveyId = urlParams['survey_id']; - + const [occurrenceSubmissionId, setOccurrenceSubmissionId] = useState(null); const [openImportObservations, setOpenImportObservations] = useState(false); const classes = useStyles(); @@ -79,32 +80,34 @@ const SurveyObservations: React.FC = () => { const dialogContext = useContext(DialogContext); - useEffect(() => { - const fetchObservationSubmission = async () => { - const submission = await biohubApi.observation.getObservationSubmission(projectId, surveyId); - - setSubmissionStatus(() => { - setIsLoading(false); - if (submission) { - if ( - submission.status === 'Rejected' || - submission.status === 'Darwin Core Validated' || - submission.status === 'Template Validated' - ) { - setIsValidating(false); - setIsPolling(false); - - setPollingTime(null); - } else { - setIsValidating(true); - setIsPolling(true); - } + const fetchObservationSubmission = useCallback(async () => { + const submission = await biohubApi.observation.getObservationSubmission(projectId, surveyId); + + setSubmissionStatus(() => { + setIsLoading(false); + if (submission) { + if ( + submission.status === 'Rejected' || + submission.status === 'Darwin Core Validated' || + submission.status === 'Template Validated' + ) { + setIsValidating(false); + setIsPolling(false); + + setPollingTime(null); + } else { + setIsValidating(true); + setIsPolling(true); } - return submission; - }); - }; + setOccurrenceSubmissionId(submission.id); + } + + return submission; + }); + }, [biohubApi.observation, projectId, surveyId]); + useEffect(() => { if (isLoading) { fetchObservationSubmission(); } @@ -113,9 +116,29 @@ const SurveyObservations: React.FC = () => { setPollingTime(2000); setPollingFunction(() => fetchObservationSubmission); } - }, [biohubApi, isLoading, isValidating, submissionStatus, projectId, surveyId]); + }, [ + biohubApi, + isLoading, + fetchObservationSubmission, + isPolling, + pollingTime, + isValidating, + submissionStatus, + projectId, + surveyId + ]); + + const softDeleteSubmission = async () => { + if (!occurrenceSubmissionId) { + return; + } - const defaultYesNoDialogProps = { + await biohubApi.observation.deleteObservationSubmission(projectId, surveyId, occurrenceSubmissionId); + + fetchObservationSubmission(); + }; + + const defaultUploadYesNoDialogProps = { dialogTitle: 'Upload Observation Data', dialogText: 'Are you sure you want to import a different data set? This will overwrite the existing data you have already imported.', @@ -125,11 +148,18 @@ const SurveyObservations: React.FC = () => { onYes: () => dialogContext.setYesNoDialog({ open: false }) }; + const defaultDeleteYesNoDialogProps = { + ...defaultUploadYesNoDialogProps, + dialogTitle: 'Delete Observation', + dialogText: + 'Are you sure you want to delete the current observation data? Your observation will be removed from this survey.' + }; + const showUploadDialog = () => { if (submissionStatus) { - // already have observation data. prompt user to confirm override + // already have observation data, prompt user to confirm override dialogContext.setYesNoDialog({ - ...defaultYesNoDialogProps, + ...defaultUploadYesNoDialogProps, open: true, onYes: () => { setOpenImportObservations(true); @@ -141,6 +171,24 @@ const SurveyObservations: React.FC = () => { } }; + const showDeleteDialog = () => { + dialogContext.setYesNoDialog({ + ...defaultDeleteYesNoDialogProps, + open: true, + onYes: () => { + softDeleteSubmission(); + dialogContext.setYesNoDialog({ open: false }); + } + }); + }; + + // Action prop for the Alert MUI component to render the delete icon and associated action + const deleteSubmissionAlertAction = () => ( + showDeleteDialog()}> + + + ); + if (isLoading) { return ; } @@ -171,7 +219,7 @@ const SurveyObservations: React.FC = () => { )} {!isValidating && submissionStatus?.status === 'Rejected' && ( <> - + {submissionStatus.fileName} Validation Failed @@ -199,7 +247,10 @@ const SurveyObservations: React.FC = () => { (submissionStatus?.status === 'Darwin Core Validated' || submissionStatus?.status === 'Template Validated') && ( <> - } severity="info"> + } + severity="info" + action={deleteSubmissionAlertAction()}> {submissionStatus.fileName} @@ -210,7 +261,10 @@ const SurveyObservations: React.FC = () => { )} {isValidating && ( <> - } severity="info"> + } + severity="info" + action={deleteSubmissionAlertAction()}> {submissionStatus.fileName} Validating observation data. Please wait ... diff --git a/app/src/hooks/api/useObservationApi.test.ts b/app/src/hooks/api/useObservationApi.test.ts index 988fe4363b..7919a37d8c 100644 --- a/app/src/hooks/api/useObservationApi.test.ts +++ b/app/src/hooks/api/useObservationApi.test.ts @@ -27,6 +27,18 @@ describe('useObservationApi', () => { expect(result.fileName).toEqual('file.txt'); }); + it('deleteObservationSubmission works as expected', async () => { + const submissionId = 1; + + mock + .onDelete(`/api/project/${projectId}/survey/${surveyId}/observation/submission/${submissionId}/delete`) + .reply(200, 1); + + const result = await useObservationApi(axios).deleteObservationSubmission(projectId, surveyId, submissionId); + + expect(result).toEqual(1); + }); + it('uploadObservationSubmission works as expected', async () => { const file = new File(['foo'], 'foo.txt', { type: 'text/plain' diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index e3faa552c7..cce928d509 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -87,6 +87,26 @@ const useObservationApi = (axios: AxiosInstance) => { return data; }; + /** + * Delete observation submission based on submission ID + * + * @param {number} projectId + * @param {number} surveyId + * @param {number} submissionId + * @returns {*} {Promise} + */ + const deleteObservationSubmission = async ( + projectId: number, + surveyId: number, + submissionId: number + ): Promise => { + const { data } = await axios.delete( + `/api/project/${projectId}/survey/${surveyId}/observation/submission/${submissionId}/delete` + ); + + return data; + }; + /** * Initiate the validation process for the submitted observations * @param {number} submissionId @@ -106,7 +126,8 @@ const useObservationApi = (axios: AxiosInstance) => { return { uploadObservationSubmission, getSubmissionCSVForView, - getObservationSubmission + getObservationSubmission, + deleteObservationSubmission }; }; diff --git a/database/src/migrations/20210819170006_add_soft_delete_timestamp_to_occurrence_submission.ts b/database/src/migrations/20210819170006_add_soft_delete_timestamp_to_occurrence_submission.ts new file mode 100644 index 0000000000..dfb2967b4b --- /dev/null +++ b/database/src/migrations/20210819170006_add_soft_delete_timestamp_to_occurrence_submission.ts @@ -0,0 +1,35 @@ +import * as Knex from 'knex'; + +const DB_SCHEMA = process.env.DB_SCHEMA; + +export async function up(knex: Knex): Promise { + await knex.raw(` + set schema '${DB_SCHEMA}'; + set search_path = ${DB_SCHEMA},public; + + ALTER TABLE ${DB_SCHEMA}.occurrence_submission add column soft_delete_timestamp timestamptz(6); + + set search_path = biohub_dapi_v1; + + set role biohub_api; + + create or replace view occurrence_submission as select * from ${DB_SCHEMA}.occurrence_submission; + + set role postgres; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(` + SET SCHEMA '${DB_SCHEMA}'; + SET SEARCH_PATH = ${DB_SCHEMA},public, biohub_dapi_v1; + + SET ROLE biohub_api; + + create or replace view occurrence_submission as select * from ${DB_SCHEMA}.occurrence_submission; + + SET ROLE postgres; + + ALTER TABLE ${DB_SCHEMA}.occurrence_submission remove column soft_delete_timestamp; + `); +}