From cfbc84f1238cea2b7c7cb3861e53db93b0e3bf14 Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Fri, 21 Jun 2024 13:40:05 -0700 Subject: [PATCH 1/3] Text input for capture and mortality locations (#1308) Add: Added input textfield boxes to manually enter capture and mortality latitude/longitude values. Updated validation of coordinates Bug fix: remove overflow hidden from the telemetry deployments list --------- Co-authored-by: Nick Phura --- api/src/paths/project/list.test.ts | 2 +- .../map/components/DrawControls.tsx | 17 +- .../map/components/ImportBoundaryDialog.tsx | 5 +- .../components/AnimalCaptureForm.tsx | 31 +++- .../location/CaptureLocationMapControl.tsx | 150 +++++++++++++----- .../components/AnimalCaptureCardContainer.tsx | 4 +- .../components/CaptureDetails.tsx | 2 +- .../components/ReleaseDetails.tsx | 2 +- .../LatitudeLongitudeTextFields.tsx | 37 +++++ .../AnimalMortalityCardContainer.tsx | 4 +- .../components/AnimalMortalityForm.tsx | 10 +- .../location/MortalityLocationMapControl.tsx | 130 ++++++++++++--- .../locations/SurveyAreaMapControl.tsx | 1 + .../map/SamplingSiteEditMapControl.tsx | 3 +- .../components/map/SamplingSiteMapControl.tsx | 3 +- .../surveys/telemetry/ManualTelemetryList.tsx | 2 +- app/src/types/yup.d.ts | 15 +- app/src/utils/Utils.tsx | 2 - app/src/utils/YupSchema.ts | 10 ++ app/src/utils/spatial-utils.ts | 36 ++++- 20 files changed, 375 insertions(+), 91 deletions(-) create mode 100644 app/src/features/surveys/animals/profile/components/LatitudeLongitudeTextFields.tsx diff --git a/api/src/paths/project/list.test.ts b/api/src/paths/project/list.test.ts index a16f575a9b..a730991df9 100644 --- a/api/src/paths/project/list.test.ts +++ b/api/src/paths/project/list.test.ts @@ -142,7 +142,7 @@ describe('list', () => { const requestHandler = list.getProjectList(); await requestHandler(sampleReq, sampleRes as any, null as unknown as any); - expect.fail(); + expect.fail('Expected an error to be thrown'); } catch (actualError) { expect(dbConnectionObj.release).to.have.been.called; diff --git a/app/src/components/map/components/DrawControls.tsx b/app/src/components/map/components/DrawControls.tsx index ab0d0f4839..6c0d8cce37 100644 --- a/app/src/components/map/components/DrawControls.tsx +++ b/app/src/components/map/components/DrawControls.tsx @@ -51,11 +51,22 @@ export interface IDrawControlsRef { /** * Adds a GeoJson feature to a new layer in the draw controls layer group. * - * @memberof IDrawControlsRef + * @param feature The GeoJson feature to add as a layer. + * @param layerId A function to receive the unique ID assigned to the added layer. */ addLayer: (feature: Feature, layerId: (id: number) => void) => void; + /** + * Deletes a layer from the draw controls layer group by its unique ID. + * + * @param layerId The unique ID of the layer to delete. + */ deleteLayer: (layerId: number) => void; + + /** + * Clears all layers from the draw controls layer group. + */ + clearLayers: () => void; } /** @@ -196,6 +207,10 @@ const DrawControls = forwardRef { const featureGroup = getFeatureGroup(); featureGroup.removeLayer(layerId); + }, + clearLayers: () => { + const featureGroup = getFeatureGroup(); + featureGroup.clearLayers(); } }), [getFeatureGroup] diff --git a/app/src/components/map/components/ImportBoundaryDialog.tsx b/app/src/components/map/components/ImportBoundaryDialog.tsx index 3e5af172b3..99b1a5f2ab 100644 --- a/app/src/components/map/components/ImportBoundaryDialog.tsx +++ b/app/src/components/map/components/ImportBoundaryDialog.tsx @@ -6,6 +6,7 @@ import { Feature } from 'geojson'; import { boundaryUploadHelper } from 'utils/mapBoundaryUploadHelpers'; export interface IImportBoundaryDialogProps { + dialogTitle: string; isOpen: boolean; onClose: () => void; onSuccess: (features: Feature[]) => void; @@ -13,9 +14,9 @@ export interface IImportBoundaryDialogProps { } const ImportBoundaryDialog = (props: IImportBoundaryDialogProps) => { - const { isOpen, onClose, onSuccess, onFailure } = props; + const { dialogTitle, isOpen, onClose, onSuccess, onFailure } = props; return ( - + diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx index eff71ba31c..ea1baae3ce 100644 --- a/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx +++ b/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx @@ -44,7 +44,13 @@ export const AnimalCaptureForm = (false); const [lastDrawn, setLastDrawn] = useState(null); + const { values, setFieldValue, setFieldError, errors } = useFormikContext(); + const drawControlsRef = useRef(); const { mapId } = props; - const { values, setFieldValue, setFieldError, errors } = useFormikContext(); - - const [updatedBounds, setUpdatedBounds] = useState(undefined); - - // Array of capture location features - const captureLocationGeoJson: Feature | undefined = useMemo(() => { - const location: { latitude: number; longitude: number } | Feature = get(values, name); + // Define location as a GeoJson object using useMemo to memoize the value + const captureLocationGeoJson: Feature | undefined = useMemo(() => { + const location: { latitude: number; longitude: number } | Feature | undefined | null = get(values, name); if (!location) { return; } - if ('latitude' in location && location.latitude !== 0 && location.longitude !== 0) { + if ( + 'latitude' in location && + 'longitude' in location && + isValidCoordinates(location.latitude, location.longitude) + ) { return { type: 'Feature', geometry: { type: 'Point', coordinates: [location.longitude, location.latitude] }, @@ -71,36 +75,85 @@ export const CaptureLocationMapControl = { - setUpdatedBounds(calculateUpdatedMapBounds([ALL_OF_BC_BOUNDARY])); + const coordinates = captureLocationGeoJson && getCoordinatesFromGeoJson(captureLocationGeoJson); + + // Initialize state based on formik context for the edit page + const [latitudeInput, setLatitudeInput] = useState(coordinates ? String(coordinates.latitude) : ''); + const [longitudeInput, setLongitudeInput] = useState(coordinates ? String(coordinates.longitude) : ''); + + const [updatedBounds, setUpdatedBounds] = useState(undefined); + // Update map bounds when the data changes + useEffect(() => { if (captureLocationGeoJson) { - if ('type' in captureLocationGeoJson) { - if (captureLocationGeoJson.geometry.type === 'Point') - if (captureLocationGeoJson?.geometry.coordinates[0] !== 0) { - setUpdatedBounds(calculateUpdatedMapBounds([captureLocationGeoJson])); - } + const { latitude, longitude } = getCoordinatesFromGeoJson(captureLocationGeoJson); + + if (isValidCoordinates(latitude, longitude)) { + setUpdatedBounds(calculateUpdatedMapBounds([captureLocationGeoJson])); } + } else { + // If the capture location is not a valid point, set the bounds to the entire province + setUpdatedBounds(calculateUpdatedMapBounds([ALL_OF_BC_BOUNDARY])); } }, [captureLocationGeoJson]); + const updateFormikLocationFromLatLon = useMemo( + () => + debounce((name, feature) => { + setFieldValue(name, feature); + }, 500), + [setFieldValue] + ); + + // Update formik and map when latitude/longitude text inputs change + useEffect(() => { + const lat = latitudeInput && parseFloat(latitudeInput); + const lon = longitudeInput && parseFloat(longitudeInput); + + if (!(lat && lon)) { + setFieldValue(name, undefined); + return; + } + + // If coordinates are invalid, reset the map to show nothing + if (!isValidCoordinates(lat, lon)) { + drawControlsRef.current?.clearLayers(); + setUpdatedBounds(calculateUpdatedMapBounds([ALL_OF_BC_BOUNDARY])); + return; + } + + const feature: Feature = { + id: 1, + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [lon, lat] + }, + properties: {} + }; + + // Update formik through debounce function + updateFormikLocationFromLatLon(name, feature); + }, [latitudeInput, longitudeInput, name, setFieldValue, updateFormikLocationFromLatLon]); + return ( - {get(errors, name) && !Array.isArray(get(errors, name)) && ( + {typeof get(errors, name) === 'string' && !Array.isArray(get(errors, name)) && ( Missing capture location - {get(errors, name) as string} + {get(errors, name)} )} setIsOpen(false)} onSuccess={(features) => { @@ -108,15 +161,16 @@ export const CaptureLocationMapControl = 1); - setLastDrawn(1); + if ('coordinates' in features[0].geometry) { + setLatitudeInput(String(features[0].geometry.coordinates[1])); + setLongitudeInput(String(features[0].geometry.coordinates[0])); + } }} onFailure={(message) => { setFieldError(name, message); }} /> - {title} + { + setLatitudeInput(event.currentTarget.value ?? ''); + }} + onLongitudeChange={(event) => { + setLongitudeInput(event.currentTarget.value ?? ''); + }} + /> - + , string | undefined>; } - export class ArraySchema extends yup.ArraySchema { + interface ArraySchema { /** * Determine if the array of classification details has duplicates * @@ -160,5 +160,16 @@ declare module 'yup' { startKey: string, endKey: string ): yup.StringSchema, string | undefined>; + + /** + * Validates that the GeoJson point coordinates (latitude/longitude) are valid + * + * @param {string} message Error message to display + * @return {*} {(yup.NumberSchema, number | undefined>)} + * @memberof ArraySchema + */ + isValidPointCoordinates( + message: string + ): yup.NumberSchema, number | undefined>; } } diff --git a/app/src/utils/Utils.tsx b/app/src/utils/Utils.tsx index 0bf55f09a3..16e635759d 100644 --- a/app/src/utils/Utils.tsx +++ b/app/src/utils/Utils.tsx @@ -412,8 +412,6 @@ export const shapeFileFeatureDesc = (geometry: Feature { + if (!value || !value.length) { + return false; + } + return isValidCoordinates(value[1], value[0]); + }); +}); + export default yup; diff --git a/app/src/utils/spatial-utils.ts b/app/src/utils/spatial-utils.ts index 4d4104a325..9cce878245 100644 --- a/app/src/utils/spatial-utils.ts +++ b/app/src/utils/spatial-utils.ts @@ -1,4 +1,4 @@ -import { Feature } from 'geojson'; +import { Feature, Point } from 'geojson'; import { isDefined } from 'utils/Utils'; /** @@ -33,3 +33,37 @@ export const getPointFeature = (param properties: { ...params.properties } }; }; + +/** + * Checks whether a latitude-longitude pair of coordinates is valid + * + * @param {number} latitude + * @param {number} longitude + * @returns boolean + */ +export const isValidCoordinates = (latitude: number | undefined, longitude: number | undefined) => { + return latitude && longitude && latitude > -90 && latitude < 90 && longitude > -180 && longitude < 180 ? true : false; +}; + +/** + * Gets latitude and longitude values from a GeoJson Point Feature. + * + * @param {Feature} feature + * @return {*} {{ latitude: number; longitude: number }} + */ +export const getCoordinatesFromGeoJson = (feature: Feature): { latitude: number; longitude: number } => { + const lon = feature.geometry.coordinates[0]; + const lat = feature.geometry.coordinates[1]; + + return { latitude: lat as number, longitude: lon as number }; +}; + +/** + * Checks if the given feature is a GeoJson Feature containing a Point. + * + * @param {(Feature | any)} [feature] + * @return {*} {feature is Feature} + */ +export const isGeoJsonPointFeature = (feature?: Feature | any): feature is Feature => { + return (feature as Feature)?.geometry.type === 'Point'; +}; From a3cb8a7d72e3fc99a0ad347dd23e0f786ee1bab6 Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:32:49 -0700 Subject: [PATCH 2/3] SIMSBIOHUB-595: Remove project dates and programs (#1309) * remove project dates and programs * Remove remaining references to program. * Drop program code table. --------- Co-authored-by: Nick Phura --- api/src/models/project-create.test.ts | 25 - api/src/models/project-create.ts | 6 - api/src/models/project-update.test.ts | 27 - api/src/models/project-update.ts | 6 - api/src/models/project-view.test.ts | 30 - api/src/models/project-view.ts | 11 +- api/src/openapi/schemas/project.ts | 28 +- api/src/openapi/schemas/survey.ts | 11 +- api/src/openapi/schemas/user.ts | 6 +- api/src/paths/administrative-activities.ts | 4 +- api/src/paths/administrative-activity.ts | 2 +- api/src/paths/codes.ts | 16 - .../funding-sources/{fundingSourceId}.ts | 4 - api/src/paths/project/list.test.ts | 14 +- api/src/paths/project/list.ts | 43 +- .../paths/project/{projectId}/survey/index.ts | 2 - .../survey/{surveyId}/attachments/list.ts | 24 +- .../{surveyObservationId}/index.ts | 8 +- api/src/paths/project/{projectId}/update.ts | 19 +- api/src/paths/project/{projectId}/view.ts | 19 +- api/src/paths/resources/list.ts | 3 +- api/src/paths/user/{userId}/get.ts | 2 +- api/src/repositories/code-repository.ts | 21 - .../repositories/project-repository.test.ts | 79 +- api/src/repositories/project-repository.ts | 97 +- api/src/services/code-service.test.ts | 1 - api/src/services/code-service.ts | 3 - api/src/services/eml-service.test.ts | 1392 ----------------- api/src/services/eml-service.ts | 1015 ------------ api/src/services/project-service.test.ts | 12 +- api/src/services/project-service.ts | 43 +- .../search-filter/ProjectAdvancedFilters.tsx | 20 - .../components/ProjectDetailsForm.test.tsx | 28 +- .../components/ProjectDetailsForm.tsx | 53 +- .../projects/edit/EditProjectForm.tsx | 8 +- .../projects/list/ProjectsListPage.test.tsx | 4 +- .../projects/list/ProjectsListPage.tsx | 30 +- .../features/projects/view/ProjectDetails.tsx | 9 - .../features/projects/view/ProjectHeader.tsx | 30 - .../view/components/GeneralInformation.tsx | 66 - .../GeneralInformationForm.tsx | 4 +- .../features/surveys/edit/EditSurveyForm.tsx | 2 - app/src/interfaces/useCodesApi.interface.ts | 1 - app/src/interfaces/useProjectApi.interface.ts | 11 - app/src/test-helpers/code-helpers.ts | 1 - app/src/test-helpers/project-helpers.ts | 6 +- .../20240620000000_project_changes.ts | 53 + .../procedures/delete_project_procedure.ts | 2 - database/src/procedures/tr_project.ts | 34 - .../seeds/03_basic_project_survey_setup.ts | 23 +- scripts/bctw-deployments/main.js | 9 +- 51 files changed, 120 insertions(+), 3247 deletions(-) delete mode 100644 api/src/services/eml-service.test.ts delete mode 100644 api/src/services/eml-service.ts delete mode 100644 app/src/features/projects/view/components/GeneralInformation.tsx create mode 100644 database/src/migrations/20240620000000_project_changes.ts delete mode 100644 database/src/procedures/tr_project.ts diff --git a/api/src/models/project-create.test.ts b/api/src/models/project-create.test.ts index 584d233fcf..8738868337 100644 --- a/api/src/models/project-create.test.ts +++ b/api/src/models/project-create.test.ts @@ -36,18 +36,6 @@ describe('PostProjectData', () => { expect(projectPostData.name).to.equal(null); }); - it('sets programs', function () { - expect(projectPostData.project_programs).to.have.length(0); - }); - - it('sets start_date', function () { - expect(projectPostData.start_date).to.equal(null); - }); - - it('sets end_date', function () { - expect(projectPostData.end_date).to.equal(null); - }); - it('sets comments', function () { expect(projectPostData.comments).to.equal(null); }); @@ -58,7 +46,6 @@ describe('PostProjectData', () => { const obj = { project_name: 'name_test_data', - project_programs: [1], start_date: 'start_date_test_data', end_date: 'end_date_test_data', comments: 'comments_test_data' @@ -72,18 +59,6 @@ describe('PostProjectData', () => { expect(projectPostData.name).to.equal('name_test_data'); }); - it('sets type', function () { - expect(projectPostData.project_programs).to.eql([1]); - }); - - it('sets start_date', function () { - expect(projectPostData.start_date).to.equal('start_date_test_data'); - }); - - it('sets end_date', function () { - expect(projectPostData.end_date).to.equal('end_date_test_data'); - }); - it('sets comments', function () { expect(projectPostData.comments).to.equal('comments_test_data'); }); diff --git a/api/src/models/project-create.ts b/api/src/models/project-create.ts index bbad22ab4b..9266b6cb79 100644 --- a/api/src/models/project-create.ts +++ b/api/src/models/project-create.ts @@ -34,18 +34,12 @@ export class PostProjectObject { */ export class PostProjectData { name: string; - project_programs: number[]; - start_date: string; - end_date: string; comments: string; constructor(obj?: any) { defaultLog.debug({ label: 'PostProjectData', message: 'params', obj }); this.name = obj?.project_name || null; - this.project_programs = obj?.project_programs || []; - this.start_date = obj?.start_date || null; - this.end_date = obj?.end_date || null; this.comments = obj?.comments || null; } } diff --git a/api/src/models/project-update.test.ts b/api/src/models/project-update.test.ts index 91770246fb..405909387a 100644 --- a/api/src/models/project-update.test.ts +++ b/api/src/models/project-update.test.ts @@ -14,18 +14,6 @@ describe('PutProjectData', () => { expect(data.name).to.equal(null); }); - it('sets type', () => { - expect(data.project_programs).to.eql([]); - }); - - it('sets start_date', () => { - expect(data.start_date).to.equal(null); - }); - - it('sets end_date', () => { - expect(data.end_date).to.equal(null); - }); - it('sets revision_count', () => { expect(data.revision_count).to.equal(null); }); @@ -34,9 +22,6 @@ describe('PutProjectData', () => { describe('all values provided', () => { const obj = { project_name: 'project name', - project_programs: [1], - start_date: '2020-04-20T07:00:00.000Z', - end_date: '2020-05-20T07:00:00.000Z', revision_count: 1 }; @@ -50,18 +35,6 @@ describe('PutProjectData', () => { expect(data.name).to.equal('project name'); }); - it('sets programs', () => { - expect(data.project_programs).to.eql([1]); - }); - - it('sets start_date', () => { - expect(data.start_date).to.eql('2020-04-20T07:00:00.000Z'); - }); - - it('sets end_date', () => { - expect(data.end_date).to.equal('2020-05-20T07:00:00.000Z'); - }); - it('sets revision_count', () => { expect(data.revision_count).to.equal(1); }); diff --git a/api/src/models/project-update.ts b/api/src/models/project-update.ts index f66364a269..a5a46f2ab4 100644 --- a/api/src/models/project-update.ts +++ b/api/src/models/project-update.ts @@ -4,18 +4,12 @@ const defaultLog = getLogger('models/project-update'); export class PutProjectData { name: string; - project_programs: number[]; - start_date: string; - end_date: string; revision_count: number; constructor(obj?: any) { defaultLog.debug({ label: 'PutProjectData', message: 'params', obj }); this.name = obj?.project_name || null; - this.project_programs = obj?.project_programs || []; - this.start_date = obj?.start_date || null; - this.end_date = obj?.end_date || null; this.revision_count = obj?.revision_count ?? null; } } diff --git a/api/src/models/project-view.test.ts b/api/src/models/project-view.test.ts index e24454106e..7a9deed4dc 100644 --- a/api/src/models/project-view.test.ts +++ b/api/src/models/project-view.test.ts @@ -17,9 +17,6 @@ describe('ProjectData', () => { project_id: 1, uuid: 'uuid', project_name: '', - project_programs: [], - start_date: '2005-01-01', - end_date: '2006-01-01', comments: '', revision_count: 1 }; @@ -32,18 +29,6 @@ describe('ProjectData', () => { it('sets name', () => { expect(data.project_name).to.equal(''); }); - - it('sets programs', () => { - expect(data.project_programs).to.eql([]); - }); - - it('sets start_date', () => { - expect(data.start_date).to.eql('2005-01-01'); - }); - - it('sets end_date', () => { - expect(data.end_date).to.eql('2006-01-01'); - }); }); describe('all values provided', () => { @@ -63,9 +48,6 @@ describe('ProjectData', () => { project_id: 1, uuid: 'uuid', project_name: 'project name', - project_programs: [1], - start_date: '2020-04-20T07:00:00.000Z', - end_date: '2020-05-20T07:00:00.000Z', comments: '', revision_count: 1 }; @@ -78,18 +60,6 @@ describe('ProjectData', () => { it('sets name', () => { expect(data.project_name).to.equal(projectData.name); }); - - it('sets type', () => { - expect(data.project_programs).to.eql([1]); - }); - - it('sets start_date', () => { - expect(data.start_date).to.eql('2020-04-20T07:00:00.000Z'); - }); - - it('sets end_date', () => { - expect(data.end_date).to.eql('2020-05-20T07:00:00.000Z'); - }); }); }); diff --git a/api/src/models/project-view.ts b/api/src/models/project-view.ts index b562ceca09..e49f79afd6 100644 --- a/api/src/models/project-view.ts +++ b/api/src/models/project-view.ts @@ -3,9 +3,6 @@ import { ProjectUser } from '../repositories/project-participation-repository'; import { SystemUser } from '../repositories/user-repository'; export interface IProjectAdvancedFilters { - project_programs?: number[]; - start_date?: string; - end_date?: string; keyword?: string; project_name?: string; itis_tsns?: number[]; @@ -22,9 +19,6 @@ export const ProjectData = z.object({ project_id: z.number(), uuid: z.string().uuid(), project_name: z.string(), - project_programs: z.array(z.number()), - start_date: z.string(), - end_date: z.string().nullable(), comments: z.string().nullable(), revision_count: z.number() }); @@ -34,10 +28,7 @@ export type ProjectData = z.infer; export const ProjectListData = z.object({ project_id: z.number(), name: z.string(), - project_programs: z.array(z.number()), - regions: z.array(z.string()), - start_date: z.string(), - end_date: z.string().nullable().optional() + regions: z.array(z.string()) }); export type ProjectListData = z.infer; diff --git a/api/src/openapi/schemas/project.ts b/api/src/openapi/schemas/project.ts index 03b4341580..e9a9f7a94d 100644 --- a/api/src/openapi/schemas/project.ts +++ b/api/src/openapi/schemas/project.ts @@ -17,13 +17,6 @@ export const projectCreatePostRequestObject = { project_name: { type: 'string' }, - project_programs: { - type: 'array', - minItems: 1, - items: { - type: 'number' - } - }, start_date: { type: 'string', description: 'ISO 8601 date string' @@ -112,7 +105,7 @@ const projectUpdateProperties = { description: 'Basic project metadata', type: 'object', additionalProperties: false, - required: ['project_name', 'project_programs', 'start_date', 'end_date', 'revision_count'], + required: ['project_name', 'revision_count'], nullable: true, properties: { project_id: { @@ -129,23 +122,6 @@ const projectUpdateProperties = { project_name: { type: 'string' }, - project_programs: { - type: 'array', - items: { - type: 'number' - } - }, - start_date: { - type: 'string', - format: 'date', - description: 'ISO 8601 date string for the project start date' - }, - end_date: { - type: 'string', - format: 'date', - description: 'ISO 8601 date string for the project end date', - nullable: true - }, revision_count: { type: 'number' } @@ -242,7 +218,7 @@ const projectUpdateProperties = { type: 'string' }, record_end_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], + type: 'string', description: 'Determines if the user record has expired', nullable: true }, diff --git a/api/src/openapi/schemas/survey.ts b/api/src/openapi/schemas/survey.ts index fb8e24eeb9..c7db5e593a 100644 --- a/api/src/openapi/schemas/survey.ts +++ b/api/src/openapi/schemas/survey.ts @@ -28,9 +28,8 @@ export const surveyDetailsSchema: OpenAPIV3.SchemaObject = { type: 'string' }, start_date: { - description: 'Survey start date', type: 'string', - format: 'date' + description: 'Survey start date. ISO 8601 date string.' }, end_date: { description: 'Survey end date', @@ -94,13 +93,11 @@ export const surveyFundingSourceSchema: OpenAPIV3.SchemaObject = { start_date: { description: 'Funding source start date', type: 'string', - format: 'date', nullable: true }, end_date: { description: 'Funding source end date', type: 'string', - format: 'date', nullable: true }, description: { @@ -266,13 +263,11 @@ export const surveyFundingSourceDataSchema: OpenAPIV3.SchemaObject = { start_date: { description: 'Funding source start date', type: 'string', - format: 'date', nullable: true }, end_date: { description: 'Funding source end date', type: 'string', - format: 'date', nullable: true }, description: { @@ -575,8 +570,8 @@ export const surveySupplementaryDataSchema: OpenAPIV3.SchemaObject = { minimum: 1 }, event_timestamp: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' + type: 'string', + description: 'ISO 8601 date string' }, submission_uuid: { type: 'string', diff --git a/api/src/openapi/schemas/user.ts b/api/src/openapi/schemas/user.ts index 3d16c96f49..598a7defcc 100644 --- a/api/src/openapi/schemas/user.ts +++ b/api/src/openapi/schemas/user.ts @@ -68,7 +68,7 @@ export const systemUserSchema: OpenAPIV3.SchemaObject = { type: 'string' }, record_end_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], + type: 'string', description: 'Determines if the user record has expired', nullable: true }, @@ -203,7 +203,7 @@ export const projectAndSystemUserSchema: OpenAPIV3.SchemaObject = { type: 'string' }, record_end_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], + type: 'string', description: 'Determines if the user record has expired', nullable: true }, @@ -277,7 +277,7 @@ export const surveyParticipationAndSystemUserSchema: OpenAPIV3.SchemaObject = { type: 'string' }, record_end_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], + type: 'string', description: 'Determines if the user record has expired', nullable: true }, diff --git a/api/src/paths/administrative-activities.ts b/api/src/paths/administrative-activities.ts index 3223ffb065..5f83783081 100644 --- a/api/src/paths/administrative-activities.ts +++ b/api/src/paths/administrative-activities.ts @@ -106,8 +106,8 @@ GET.apiDoc = { nullable: true }, create_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' + type: 'string', + description: 'ISO 8601 date string' } } } diff --git a/api/src/paths/administrative-activity.ts b/api/src/paths/administrative-activity.ts index c370a4fe82..04dec02ae5 100644 --- a/api/src/paths/administrative-activity.ts +++ b/api/src/paths/administrative-activity.ts @@ -48,7 +48,7 @@ POST.apiDoc = { }, date: { description: 'The date this administrative activity was made', - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }] + type: 'string' } } } diff --git a/api/src/paths/codes.ts b/api/src/paths/codes.ts index 606ffdca45..882107504e 100644 --- a/api/src/paths/codes.ts +++ b/api/src/paths/codes.ts @@ -30,7 +30,6 @@ GET.apiDoc = { 'iucn_conservation_action_level_2_subclassification', 'iucn_conservation_action_level_3_subclassification', 'proprietor_type', - 'program', 'system_roles', 'project_roles', 'administrative_activity_status_type', @@ -189,21 +188,6 @@ GET.apiDoc = { } } }, - program: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - properties: { - id: { - type: 'number' - }, - name: { - type: 'string' - } - } - } - }, system_roles: { type: 'array', items: { diff --git a/api/src/paths/funding-sources/{fundingSourceId}.ts b/api/src/paths/funding-sources/{fundingSourceId}.ts index 349ccb34ab..b630fef7d7 100644 --- a/api/src/paths/funding-sources/{fundingSourceId}.ts +++ b/api/src/paths/funding-sources/{fundingSourceId}.ts @@ -89,12 +89,10 @@ GET.apiDoc = { }, start_date: { type: 'string', - format: 'date', nullable: true }, end_date: { type: 'string', - format: 'date', nullable: true } } @@ -252,12 +250,10 @@ PUT.apiDoc = { }, start_date: { type: 'string', - format: 'date', nullable: true }, end_date: { type: 'string', - format: 'date', nullable: true }, revision_count: { diff --git a/api/src/paths/project/list.test.ts b/api/src/paths/project/list.test.ts index a730991df9..20674b8a09 100644 --- a/api/src/paths/project/list.test.ts +++ b/api/src/paths/project/list.test.ts @@ -7,7 +7,7 @@ import { SYSTEM_ROLE } from '../../constants/roles'; import * as db from '../../database/db'; import { HTTPError } from '../../errors/http-error'; import * as authorization from '../../request-handlers/security/authorization'; -import { COMPLETION_STATUS, ProjectService } from '../../services/project-service'; +import { ProjectService } from '../../services/project-service'; import { getMockDBConnection } from '../../__mocks__/db'; import * as list from './list'; @@ -94,11 +94,7 @@ describe('list', () => { { project_id: 1, name: 'myproject', - project_programs: [1], - start_date: '2022-02-02', - end_date: null, - regions: [], - completion_status: COMPLETION_STATUS.COMPLETED + regions: [] } ]); sinon.stub(ProjectService.prototype, 'getProjectCount').resolves(1); @@ -120,11 +116,7 @@ describe('list', () => { { project_id: 1, name: 'myproject', - project_programs: [1], - start_date: '2022-02-02', - end_date: null, - regions: [], - completion_status: COMPLETION_STATUS.COMPLETED + regions: [] } ] }); diff --git a/api/src/paths/project/list.ts b/api/src/paths/project/list.ts index aeb1bbcf70..fac26a5220 100644 --- a/api/src/paths/project/list.ts +++ b/api/src/paths/project/list.ts @@ -51,13 +51,6 @@ GET.apiDoc = { type: 'string', nullable: true }, - project_programs: { - type: 'array', - items: { - type: 'integer' - }, - nullable: true - }, keyword: { type: 'string', nullable: true @@ -92,15 +85,7 @@ GET.apiDoc = { items: { type: 'object', additionalProperties: false, - required: [ - 'project_id', - 'name', - 'project_programs', - 'completion_status', - 'start_date', - 'end_date', - 'regions' - ], + required: ['project_id', 'name', 'regions'], properties: { project_id: { type: 'integer' @@ -108,30 +93,11 @@ GET.apiDoc = { name: { type: 'string' }, - project_programs: { - type: 'array', - items: { - type: 'integer' - } - }, - start_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the funding end_date' - }, - end_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - nullable: true, - description: 'ISO 8601 date string for the funding end_date' - }, regions: { type: 'array', items: { type: 'string' } - }, - completion_status: { - type: 'string', - enum: ['Completed', 'Active'] } } } @@ -182,12 +148,7 @@ export function getProjectList(): RequestHandler { const filterFields: IProjectAdvancedFilters = { keyword: req.query.keyword && String(req.query.keyword), project_name: req.query.project_name && String(req.query.project_name), - project_programs: req.query.project_programs - ? String(req.query.project_programs).split(',').map(Number) - : undefined, - itis_tsns: req.query.itis_tsns ? String(req.query.itis_tsns).split(',').map(Number) : undefined, - start_date: req.query.start_date && String(req.query.start_date), - end_date: req.query.end_date && String(req.query.end_date) + itis_tsns: req.query.itis_tsns ? String(req.query.itis_tsns).split(',').map(Number) : undefined }; const paginationOptions = makePaginationOptionsFromRequest(req); diff --git a/api/src/paths/project/{projectId}/survey/index.ts b/api/src/paths/project/{projectId}/survey/index.ts index 15d1e31719..6487b48837 100644 --- a/api/src/paths/project/{projectId}/survey/index.ts +++ b/api/src/paths/project/{projectId}/survey/index.ts @@ -85,12 +85,10 @@ GET.apiDoc = { }, start_date: { type: 'string', - format: 'date', description: 'ISO 8601 date string' }, end_date: { type: 'string', - format: 'date', description: 'ISO 8601 date string', nullable: true }, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/list.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/list.ts index 2f2f6e82ff..8bd09bedd0 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/list.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/list.ts @@ -117,23 +117,23 @@ GET.apiDoc = { type: 'number' }, event_timestamp: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' + type: 'string', + description: 'ISO 8601 date string' }, artifact_revision_id: { type: 'string' }, create_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' + type: 'string', + description: 'ISO 8601 date string' }, create_user: { type: 'integer', minimum: 1 }, update_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date', + type: 'string', + description: 'ISO 8601 date string', nullable: true }, update_user: { @@ -198,23 +198,23 @@ GET.apiDoc = { type: 'number' }, event_timestamp: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' + type: 'string', + description: 'ISO 8601 date string' }, artifact_revision_id: { type: 'string' }, create_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' + type: 'string', + description: 'ISO 8601 date string' }, create_user: { type: 'integer', minimum: 1 }, update_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date', + type: 'string', + description: 'ISO 8601 date string', nullable: true }, update_user: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/{surveyObservationId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/{surveyObservationId}/index.ts index 993a3d57b3..234633a6e6 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/{surveyObservationId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/{surveyObservationId}/index.ts @@ -137,16 +137,16 @@ GET.apiDoc = { nullable: true }, create_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' + type: 'string', + description: 'ISO 8601 date string' }, create_user: { type: 'integer', minimum: 1 }, update_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date', + type: 'string', + description: 'ISO 8601 date string', nullable: true }, update_user: { diff --git a/api/src/paths/project/{projectId}/update.ts b/api/src/paths/project/{projectId}/update.ts index c08813966a..209cb0230a 100644 --- a/api/src/paths/project/{projectId}/update.ts +++ b/api/src/paths/project/{projectId}/update.ts @@ -83,7 +83,7 @@ GET.apiDoc = { description: 'Basic project metadata', type: 'object', additionalProperties: false, - required: ['project_name', 'project_programs', 'start_date', 'end_date', 'revision_count'], + required: ['project_name', 'revision_count'], nullable: true, properties: { project_id: { @@ -100,23 +100,6 @@ GET.apiDoc = { project_name: { type: 'string' }, - project_programs: { - type: 'array', - items: { - type: 'number' - } - }, - start_date: { - type: 'string', - format: 'date', - description: 'ISO 8601 date string for the project start date' - }, - end_date: { - type: 'string', - format: 'date', - description: 'ISO 8601 date string for the project end date', - nullable: true - }, revision_count: { type: 'number' } diff --git a/api/src/paths/project/{projectId}/view.ts b/api/src/paths/project/{projectId}/view.ts index 5307e00748..af1c845de1 100644 --- a/api/src/paths/project/{projectId}/view.ts +++ b/api/src/paths/project/{projectId}/view.ts @@ -70,7 +70,7 @@ GET.apiDoc = { description: 'Basic project metadata', type: 'object', additionalProperties: false, - required: ['project_id', 'uuid', 'project_name', 'project_programs', 'start_date', 'comments'], + required: ['project_id', 'uuid', 'project_name', 'comments'], properties: { project_id: { type: 'integer', @@ -83,23 +83,6 @@ GET.apiDoc = { project_name: { type: 'string' }, - project_programs: { - type: 'array', - items: { - type: 'number' - } - }, - start_date: { - type: 'string', - format: 'date', - description: 'ISO 8601 date string for the project start date' - }, - end_date: { - type: 'string', - format: 'date', - description: 'ISO 8601 date string for the project end date', - nullable: true - }, comments: { type: 'string', nullable: true, diff --git a/api/src/paths/resources/list.ts b/api/src/paths/resources/list.ts index 6a025e2ac1..b3ea45fb8c 100644 --- a/api/src/paths/resources/list.ts +++ b/api/src/paths/resources/list.ts @@ -36,7 +36,8 @@ GET.apiDoc = { type: 'string' }, lastModified: { - oneOf: [{ type: 'string', format: 'date' }, { type: 'object' }] + type: 'string', + description: 'ISO 8601 date string' }, fileSize: { type: 'number' diff --git a/api/src/paths/user/{userId}/get.ts b/api/src/paths/user/{userId}/get.ts index 9a1dd0122c..e1ad449945 100644 --- a/api/src/paths/user/{userId}/get.ts +++ b/api/src/paths/user/{userId}/get.ts @@ -85,7 +85,7 @@ GET.apiDoc = { nullable: true }, record_end_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], + type: 'string', nullable: true, description: 'Determines if the user record has expired' }, diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index 93003addf7..cd5550dd74 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -27,7 +27,6 @@ export const IAllCodeSets = z.object({ agency: CodeSet(), investment_action_category: CodeSet(InvestmentActionCategoryCode.shape), type: CodeSet(), - program: CodeSet(), proprietor_type: CodeSet(ProprietorTypeCode.shape), iucn_conservation_action_level_1_classification: CodeSet(), iucn_conservation_action_level_2_subclassification: CodeSet(IucnConservationActionLevel2SubclassificationCode.shape), @@ -212,26 +211,6 @@ export class CodeRepository extends BaseRepository { return response.rows; } - /** - * Fetch project type codes. - * - * @return {*} - * @memberof CodeRepository - */ - async getProgram() { - const sqlStatement = SQL` - SELECT - program_id as id, - name - FROM program - WHERE record_end_date is null; - `; - - const response = await this.connection.sql(sqlStatement, ICode); - - return response.rows; - } - /** * Fetch investment action category codes. * diff --git a/api/src/repositories/project-repository.test.ts b/api/src/repositories/project-repository.test.ts index bed645f3d1..3c92c404fb 100644 --- a/api/src/repositories/project-repository.test.ts +++ b/api/src/repositories/project-repository.test.ts @@ -1,9 +1,7 @@ import chai, { expect } from 'chai'; import { describe } from 'mocha'; import { QueryResult } from 'pg'; -import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { ApiExecuteSQLError } from '../errors/api-error'; import { PostProjectObject } from '../models/project-create'; import { GetAttachmentsData, @@ -25,8 +23,6 @@ describe('ProjectRepository', () => { const repository = new ProjectRepository(dbConnection); const input = { - start_date: 'start', - end_date: undefined, project_name: 'string', agency_project_id: 1, agency_id: 1, @@ -46,9 +42,6 @@ describe('ProjectRepository', () => { const repository = new ProjectRepository(dbConnection); const input = { - start_date: undefined, - end_date: 'end', - project_programs: [1], project_name: 'string', agency_project_id: 1, agency_id: 1, @@ -68,8 +61,7 @@ describe('ProjectRepository', () => { const repository = new ProjectRepository(dbConnection); const input = { - start_date: 'start', - end_date: 'end' + keyword: 'a' }; const response = await repository.getProjectList(true, 1, input); @@ -246,10 +238,7 @@ describe('ProjectRepository', () => { const input = { project: { - project_programs: [1], name: 'name', - start_date: 'start_date', - end_date: 'end_date', comments: 'comments' }, objectives: { objectives: '' } @@ -268,10 +257,7 @@ describe('ProjectRepository', () => { const input = { project: { - project_programs: [1], name: 'name', - start_date: 'start_date', - end_date: 'end_date', comments: 'comments' }, objectives: { objectives: '' } @@ -290,10 +276,7 @@ describe('ProjectRepository', () => { const input = { project: { - project_programs: [1], name: 'name', - start_date: 'start_date', - end_date: 'end_date', comments: 'comments' }, objectives: { objectives: '' } @@ -360,64 +343,4 @@ describe('ProjectRepository', () => { expect(response).to.eql(undefined); }); }); - - describe('insertProgram', () => { - it('should return early', async () => { - const dbConnection = getMockDBConnection(); - const mockSql = sinon.stub(dbConnection, 'sql').resolves(); - const repository = new ProjectRepository(dbConnection); - - await repository.insertProgram(1, []); - - expect(mockSql).to.not.be.called; - }); - - it('should run properly', async () => { - const dbConnection = getMockDBConnection(); - const mockSql = sinon.stub(dbConnection, 'sql').resolves(); - const repository = new ProjectRepository(dbConnection); - - await repository.insertProgram(1, [1]); - - expect(mockSql).to.be.called; - }); - - it('should throw an SQL error', async () => { - const dbConnection = getMockDBConnection(); - sinon.stub(dbConnection, 'sql').rejects(); - const repository = new ProjectRepository(dbConnection); - - try { - await repository.insertProgram(1, [1]); - expect.fail(); - } catch (error) { - expect((error as ApiExecuteSQLError).message).to.equal('Failed to execute insert SQL for project_program'); - } - }); - }); - - describe('deletePrograms', () => { - it('should run without issue', async () => { - const dbConnection = getMockDBConnection(); - const mockSql = sinon.stub(dbConnection, 'sql').resolves(); - const repository = new ProjectRepository(dbConnection); - - await repository.deletePrograms(1); - - expect(mockSql).to.be.called; - }); - - it('should throw an SQL error', async () => { - const dbConnection = getMockDBConnection(); - sinon.stub(dbConnection, 'sql').rejects(); - const repository = new ProjectRepository(dbConnection); - - try { - await repository.deletePrograms(1); - expect.fail(); - } catch (error) { - expect((error as ApiExecuteSQLError).message).to.equal('Failed to execute delete SQL for project_program'); - } - }); - }); }); diff --git a/api/src/repositories/project-repository.ts b/api/src/repositories/project-repository.ts index ffa27ad4f2..6066302fed 100644 --- a/api/src/repositories/project-repository.ts +++ b/api/src/repositories/project-repository.ts @@ -48,21 +48,15 @@ export class ProjectRepository extends BaseRepository { .select([ 'p.project_id', 'p.name', - 'p.start_date', - 'p.end_date', - knex.raw(`COALESCE(array_remove(array_agg(DISTINCT rl.region_name), null), '{}') as regions`), - knex.raw('array_agg(distinct prog.program_id) as project_programs') + knex.raw(`COALESCE(array_remove(array_agg(DISTINCT rl.region_name), null), '{}') as regions`) ]) .from('project as p') - - .leftJoin('project_program as pp', 'p.project_id', 'pp.project_id') .leftJoin('survey as s', 's.project_id', 'p.project_id') .leftJoin('study_species as sp', 'sp.survey_id', 's.survey_id') - .leftJoin('program as prog', 'prog.program_id', 'pp.program_id') .leftJoin('survey_region as sr', 'sr.survey_id', 's.survey_id') .leftJoin('region_lookup as rl', 'sr.region_id', 'rl.region_id') - .groupBy(['p.project_id', 'p.name', 'p.objectives', 'p.start_date', 'p.end_date']); + .groupBy(['p.project_id', 'p.name', 'p.objectives']); /* * Ensure that users can only see project that they are participating in, unless @@ -74,16 +68,6 @@ export class ProjectRepository extends BaseRepository { }); } - // Start Date filter - if (filterFields.start_date) { - query.andWhere('p.start_date', '>=', filterFields.start_date); - } - - // End Date filter - if (filterFields.end_date) { - query.andWhere('p.end_date', '<=', filterFields.end_date); - } - // Project Name filter (exact match) if (filterFields.project_name) { query.andWhere('p.name', filterFields.project_name); @@ -105,11 +89,6 @@ export class ProjectRepository extends BaseRepository { }); } - // Programs filter - if (filterFields.project_programs?.length) { - query.where('prog.program_id', 'IN', filterFields.project_programs); - } - return query; } @@ -186,19 +165,10 @@ export class ProjectRepository extends BaseRepository { p.project_id, p.uuid, p.name as project_name, - p.start_date, - p.end_date, p.comments, - p.revision_count, - pp.project_programs + p.revision_count FROM project p - LEFT JOIN ( - SELECT array_remove(array_agg(p.program_id), NULL) as project_programs, pp.project_id - FROM program p, project_program pp - WHERE p.program_id = pp.program_id - GROUP BY pp.project_id - ) as pp on pp.project_id = p.project_id WHERE p.project_id = ${projectId}; `; @@ -327,14 +297,10 @@ export class ProjectRepository extends BaseRepository { INSERT INTO project ( name, objectives, - start_date, - end_date, comments ) VALUES ( ${postProjectData.project.name}, ${postProjectData.objectives.objectives}, - ${postProjectData.project.start_date}, - ${postProjectData.project.end_date}, ${postProjectData.project.comments} ) RETURNING project_id as id;`; @@ -379,61 +345,6 @@ export class ProjectRepository extends BaseRepository { return result.id; } - /** - * Links a given project with a list of given programs. - * This insert assumes previous records for a project have been removed first - * - * @param {number} projectId Project to add programs to - * @param {number[]} programs Programs to be added to a project - * @returns {*} {Promise} - */ - async insertProgram(projectId: number, programs: number[]): Promise { - if (programs.length < 1) { - return; - } - - const sql = SQL` - INSERT INTO project_program (project_id, program_id) - VALUES `; - - programs.forEach((programId, index) => { - sql.append(`(${projectId}, ${programId})`); - - if (index !== programs.length - 1) { - sql.append(','); - } - }); - - sql.append(';'); - - try { - await this.connection.sql(sql); - } catch (error) { - throw new ApiExecuteSQLError('Failed to execute insert SQL for project_program', [ - 'ProjectRepository->insertProgram' - ]); - } - } - - /** - * Removes program links for a given project. - * - * @param {number} projectId Project id to remove programs from - * @returns {*} {Promise} - */ - async deletePrograms(projectId: number): Promise { - const sql = SQL` - DELETE FROM project_program WHERE project_id = ${projectId}; - `; - try { - await this.connection.sql(sql); - } catch (error) { - throw new ApiExecuteSQLError('Failed to execute delete SQL for project_program', [ - 'ProjectRepository->deletePrograms' - ]); - } - } - async deleteIUCNData(projectId: number): Promise { const sqlDeleteStatement = SQL` DELETE @@ -465,8 +376,6 @@ export class ProjectRepository extends BaseRepository { if (project) { sqlSetStatements.push(SQL`name = ${project.name}`); - sqlSetStatements.push(SQL`start_date = ${project.start_date}`); - sqlSetStatements.push(SQL`end_date = ${project.end_date}`); } if (objectives) { diff --git a/api/src/services/code-service.test.ts b/api/src/services/code-service.test.ts index fb396dbe02..0e88eed428 100644 --- a/api/src/services/code-service.test.ts +++ b/api/src/services/code-service.test.ts @@ -31,7 +31,6 @@ describe('CodeService', () => { 'agency', 'investment_action_category', 'type', - 'program', 'proprietor_type', 'iucn_conservation_action_level_1_classification', 'iucn_conservation_action_level_2_subclassification', diff --git a/api/src/services/code-service.ts b/api/src/services/code-service.ts index 55c1481250..8802e52ed0 100644 --- a/api/src/services/code-service.ts +++ b/api/src/services/code-service.ts @@ -34,7 +34,6 @@ export class CodeService extends DBService { iucn_conservation_action_level_2_subclassification, iucn_conservation_action_level_3_subclassification, proprietor_type, - program, system_roles, project_roles, administrative_activity_status_type, @@ -55,7 +54,6 @@ export class CodeService extends DBService { await this.codeRepository.getIUCNConservationActionLevel2Subclassification(), await this.codeRepository.getIUCNConservationActionLevel3Subclassification(), await this.codeRepository.getProprietorType(), - await this.codeRepository.getProgram(), await this.codeRepository.getSystemRoles(), await this.codeRepository.getProjectRoles(), await this.codeRepository.getAdministrativeActivityStatusType(), @@ -77,7 +75,6 @@ export class CodeService extends DBService { iucn_conservation_action_level_1_classification, iucn_conservation_action_level_2_subclassification, iucn_conservation_action_level_3_subclassification, - program, proprietor_type, system_roles, project_roles, diff --git a/api/src/services/eml-service.test.ts b/api/src/services/eml-service.test.ts deleted file mode 100644 index 6ba01f0e13..0000000000 --- a/api/src/services/eml-service.test.ts +++ /dev/null @@ -1,1392 +0,0 @@ -import chai, { expect } from 'chai'; -import { describe } from 'mocha'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { IGetProject } from '../models/project-view'; -import { SurveyObject } from '../models/survey-view'; -import { getMockDBConnection } from '../__mocks__/db'; -import { CodeService } from './code-service'; -import { EmlPackage, EmlService } from './eml-service'; -import { ProjectService } from './project-service'; -import { SurveyService } from './survey-service'; - -chai.use(sinonChai); - -describe('EmlPackage', () => { - describe('withEml', () => { - it('should build an EML section', () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - const packageId = 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii'; - - const emlPackage = new EmlPackage({ packageId }); - - const response = emlPackage.withEml(emlService._buildEmlSection(packageId)); - - expect(response._emlMetadata).to.eql(emlPackage._emlMetadata); - expect(response._emlMetadata).to.eql({ - $: { - packageId: 'urn:uuid:aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', - system: '', - 'xmlns:eml': 'https://eml.ecoinformatics.org/eml-2.2.0', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xmlns:stmml': 'http://www.xml-cml.org/schema/schema24', - 'xsi:schemaLocation': 'https://eml.ecoinformatics.org/eml-2.2.0 xsd/eml.xsd' - } - }); - }); - }); - - describe('withDataset', () => { - it('should build an EML dataset section', () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const mockOrg = { - organizationName: 'Test Organization', - electronicMailAddress: 'EMAIL@address.com' - }; - - const mockPackageId = 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii'; - const mockProjectData = { - project: { - project_name: 'Test Project Name' - } - } as IGetProject; - - const emlPackage = new EmlPackage({ packageId: mockPackageId }); - - sinon.stub(EmlService.prototype, '_getProjectDatasetCreator').returns(mockOrg); - - sinon.stub(EmlService.prototype, '_makeEmlDateString').returns('2023-01-01'); - - sinon.stub(EmlService.prototype, '_getProjectContact').returns({ - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - ...mockOrg - }); - - const response = emlPackage.withDataset( - emlService._buildProjectEmlDatasetSection(mockPackageId, mockProjectData) - ); - - expect(response._datasetMetadata).to.eql(emlPackage._datasetMetadata); - expect(response._datasetMetadata).to.eql({ - $: { system: '', id: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii' }, - title: 'Test Project Name', - creator: { - organizationName: 'Test Organization', - electronicMailAddress: 'EMAIL@address.com' - }, - pubDate: '2023-01-01', - language: 'English', - contact: { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - organizationName: 'Test Organization', - electronicMailAddress: 'EMAIL@address.com' - } - }); - }); - }); - - describe('withProject', () => { - it('should build a project EML Project section', () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const mockPackageId = 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii'; - - const mockProjectData = { - project: { - uuid: mockPackageId, - project_name: 'Test Project Name' - }, - objectives: { - objectives: 'Project objectives.' - } - } as IGetProject; - - const emlPackage = new EmlPackage({ packageId: mockPackageId }); - - sinon.stub(EmlService.prototype, '_getProjectPersonnel').returns([ - { - individualName: { givenName: 'First Name', surName: 'Last Name' }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ]); - - sinon.stub(EmlService.prototype, '_getProjectTemporalCoverage').returns({ - rangeOfDates: { - beginDate: { calendarDate: '2023-01-01' }, - endDate: { calendarDate: '2023-01-31' } - } - }); - - const response = emlPackage.withProject(emlService._buildProjectEmlProjectSection(mockProjectData, [])); - - expect(response._projectMetadata).to.eql(emlPackage._projectMetadata); - expect(response._projectMetadata).to.eql({ - $: { id: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', system: '' }, - title: 'Test Project Name', - personnel: [ - { - individualName: { givenName: 'First Name', surName: 'Last Name' }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ], - abstract: { - section: [{ title: 'Objectives', para: 'Project objectives.' }] - }, - studyAreaDescription: { - coverage: { - temporalCoverage: { - rangeOfDates: { - beginDate: { calendarDate: '2023-01-01' }, - endDate: { calendarDate: '2023-01-31' } - } - } - } - } - }); - }); - }); - - describe('withAdditionalMetadata', () => { - it('should add additional metadata to the EML package', () => { - const additionalMeta1 = [ - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { projectTypes: { projectType: 'Aquatic Habitat' } } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - projectActivities: { - projectActivity: [{ name: 'Habitat Protection' }] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - IUCNConservationActions: { - IUCNConservationAction: [ - { - IUCNConservationActionLevel1Classification: 'Awareness Raising', - IUCNConservationActionLevel2SubClassification: 'Outreach & Communications', - IUCNConservationActionLevel3SubClassification: 'Reported and social media' - } - ] - } - } - } - ]; - - const additionalMeta2 = [ - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - stakeholderPartnerships: { - stakeholderPartnership: [{ name: 'BC Hydro' }] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - firstNationPartnerships: { - firstNationPartnership: [{ name: 'Acho Dene Koe First Nation' }] - } - } - } - ]; - - const emlPackage = new EmlPackage({ packageId: null as unknown as string }); - - const response = emlPackage.withAdditionalMetadata(additionalMeta1).withAdditionalMetadata(additionalMeta2); - - expect(response._additionalMetadata).to.eql(emlPackage._additionalMetadata); - expect(emlPackage._additionalMetadata).to.eql([...additionalMeta1, ...additionalMeta2]); - }); - }); - - describe('withRelatedProjects', () => { - it('should add a related project to the EML package', () => { - const project = { - $: { id: '1116c94a-8cd5-480d-a1f3-dac794e57c05', system: '' }, - title: 'Project Name 1' - }; - - const emlPackage = new EmlPackage({ packageId: null as unknown as string }); - - const response = emlPackage.withRelatedProjects([project]); - - expect(response._relatedProjects).to.eql(emlPackage._relatedProjects); - expect(emlPackage._relatedProjects).to.eql([ - { - $: { id: '1116c94a-8cd5-480d-a1f3-dac794e57c05', system: '' }, - title: 'Project Name 1' - } - ]); - }); - - it('should add multiple related projects to the EML package', () => { - const project1 = { - $: { id: '1116c94a-8cd5-480d-a1f3-dac794e57c05', system: '' }, - title: 'Project Name 1' - }; - - const project2 = { - $: { id: '1116c94a-8cd5-480d-a1f3-dac794e57c06', system: '' }, - title: 'Project Name 2' - }; - - const emlPackage = new EmlPackage({ packageId: null as unknown as string }); - - const response = emlPackage.withRelatedProjects([project1]).withRelatedProjects([project2]); - - expect(response._relatedProjects).to.eql(emlPackage._relatedProjects); - expect(emlPackage._relatedProjects).to.eql([ - { - $: { id: '1116c94a-8cd5-480d-a1f3-dac794e57c05', system: '' }, - title: 'Project Name 1' - }, - { - $: { id: '1116c94a-8cd5-480d-a1f3-dac794e57c06', system: '' }, - title: 'Project Name 2' - } - ]); - }); - }); - - describe('build', () => { - // - }); -}); - -describe.skip('EmlService', () => { - beforeEach(() => { - sinon.stub(EmlService.prototype, 'loadEmlDbConstants').callsFake(async function (this: EmlService) { - this._constants.EML_ORGANIZATION_URL = 'Not Supplied'; - this._constants.EML_ORGANIZATION_NAME = 'Not Supplied'; - this._constants.EML_PROVIDER_URL = 'Not Supplied'; - this._constants.EML_SECURITY_PROVIDER_URL = 'Not Supplied'; - this._constants.EML_ORGANIZATION_URL = 'Not Supplied'; - this._constants.EML_INTELLECTUAL_RIGHTS = 'Not Supplied'; - this._constants.EML_TAXONOMIC_PROVIDER_URL = 'Not Supplied'; - }); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('constructs', () => { - const dbConnectionObj = getMockDBConnection(); - - const emlService = new EmlService(dbConnectionObj); - - expect(emlService).to.be.instanceof(EmlService); - expect(emlService._constants).to.eql({ - EML_VERSION: '1.0.0', - EML_PROVIDER_URL: 'Not Supplied', - EML_SECURITY_PROVIDER_URL: 'Not Supplied', - EML_ORGANIZATION_NAME: 'Not Supplied', - EML_ORGANIZATION_URL: 'Not Supplied', - EML_TAXONOMIC_PROVIDER_URL: 'Not Supplied', - EML_INTELLECTUAL_RIGHTS: 'Not Supplied' - }); - }); - - describe('buildProjectEmlPackage', () => { - it('should build an EML string with no content if no data is provided', async () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - sinon - .stub(ProjectService.prototype, 'getProjectById') - .resolves({ project: { uuid: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii' } } as IGetProject); - - sinon.stub(SurveyService.prototype, 'getSurveysByProjectId').resolves([]); - - sinon.stub(EmlService.prototype, '_buildEmlSection').returns({}); - sinon.stub(EmlService.prototype, '_buildProjectEmlDatasetSection').resolves({}); - sinon.stub(EmlService.prototype, '_buildProjectEmlProjectSection').returns({}); - sinon.stub(EmlService.prototype, '_getProjectAdditionalMetadata').resolves([]); - sinon.stub(EmlService.prototype, '_getSurveyAdditionalMetadata').resolves([]); - sinon.stub(EmlService.prototype, '_buildAllSurveyEmlProjectSections').resolves([]); - - const emlPackage = await emlService.buildProjectEmlPackage({ projectId: 1 }); - - expect(emlPackage.toString()).to.equal( - `` - ); - }); - - it('should build an EML package for a project successfully', async () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - sinon - .stub(ProjectService.prototype, 'getProjectById') - .resolves({ project: { uuid: '1116c94a-8cd5-480d-a1f3-dac794e57c05' } } as IGetProject); - - sinon.stub(SurveyService.prototype, 'getSurveysByProjectId').resolves([]); - - // Build EML section - sinon.stub(EmlService.prototype, '_buildEmlSection').returns({ - $: { - packageId: 'urn:uuid:1116c94a-8cd5-480d-a1f3-dac794e57c05', - system: '', - 'xmlns:eml': 'https://eml.ecoinformatics.org/eml-2.2.0', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xmlns:stmml': 'http://www.xml-cml.org/schema/schema24', - 'xsi:schemaLocation': 'https://eml.ecoinformatics.org/eml-2.2.0 xsd/eml.xsd' - } - }); - - // Build dataset EML section - sinon.stub(EmlService.prototype, '_buildProjectEmlDatasetSection').returns({ - $: { - system: '', - id: '1116c94a-8cd5-480d-a1f3-dac794e57c05' - }, - title: 'Project Name', - creator: { - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com' - }, - pubDate: '2023-03-13', - language: 'English', - contact: { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com' - } - }); - - // Build Project EML section - sinon.stub(EmlService.prototype, '_buildProjectEmlProjectSection').returns({ - $: { id: '1116c94a-8cd5-480d-a1f3-dac794e57c05', system: '' }, - title: 'Project Name', - personnel: [ - { - individualName: { givenName: 'First Name', surName: 'Last Name' }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ], - abstract: { - section: [{ title: 'Objectives', para: 'Objectives' }] - }, - studyAreaDescription: { - coverage: { - geographicCoverage: { - geographicDescription: 'Location Description', - boundingCoordinates: { - westBoundingCoordinate: -121.904297, - eastBoundingCoordinate: -120.19043, - northBoundingCoordinate: 51.971346, - southBoundingCoordinate: 50.930738 - }, - datasetGPolygon: [ - { - datasetGPolygonOuterGRing: [ - { - gRingPoint: [ - { gRingLatitude: 50.930738, gRingLongitude: -121.904297 }, - { gRingLatitude: 51.971346, gRingLongitude: -121.904297 }, - { gRingLatitude: 51.971346, gRingLongitude: -120.19043 }, - { gRingLatitude: 50.930738, gRingLongitude: -120.19043 }, - { gRingLatitude: 50.930738, gRingLongitude: -121.904297 } - ] - } - ] - } - ] - }, - temporalCoverage: { - rangeOfDates: { - beginDate: { calendarDate: '2023-01-01' }, - endDate: { calendarDate: '2023-01-31' } - } - } - } - } - }); - - // Build Project additional metadata - sinon.stub(EmlService.prototype, '_getProjectAdditionalMetadata').resolves([ - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { projectTypes: { projectType: 'Aquatic Habitat' } } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - projectActivities: { - projectActivity: [{ name: 'Habitat Protection' }] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - IUCNConservationActions: { - IUCNConservationAction: [ - { - IUCNConservationActionLevel1Classification: 'Awareness Raising', - IUCNConservationActionLevel2SubClassification: 'Outreach & Communications', - IUCNConservationActionLevel3SubClassification: 'Reported and social media' - } - ] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - stakeholderPartnerships: { - stakeholderPartnership: [{ name: 'BC Hydro' }] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - firstNationPartnerships: { - firstNationPartnership: [{ name: 'Acho Dene Koe First Nation' }] - } - } - } - ]); - - // Build survey additional metadata - sinon.stub(EmlService.prototype, '_getSurveyAdditionalMetadata').resolves([]); - - // Build related project section - sinon.stub(EmlService.prototype, '_buildAllSurveyEmlProjectSections').resolves([ - { - $: { - id: '69b506d1-3a50-4a39-b4c7-190bd0b34b96', - system: '' - }, - title: 'Survey Name', - personnel: [ - { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - role: 'pointOfContact' - } - ], - abstract: { - section: [ - { - title: 'Intended Outcomes', - para: 'Habitat Assessment' - }, - { - title: 'Additional Details', - para: 'Additional Details' - } - ] - }, - studyAreaDescription: { - coverage: { - geographicCoverage: { - geographicDescription: 'Survey Area Name', - boundingCoordinates: { - westBoundingCoordinate: -121.904297, - eastBoundingCoordinate: -120.19043, - northBoundingCoordinate: 51.971346, - southBoundingCoordinate: 50.930738 - }, - datasetGPolygon: [ - { - datasetGPolygonOuterGRing: [ - { - gRingPoint: [ - { - gRingLatitude: 50.930738, - gRingLongitude: -121.904297 - }, - { - gRingLatitude: 51.971346, - gRingLongitude: -121.904297 - }, - { - gRingLatitude: 51.971346, - gRingLongitude: -120.19043 - }, - { - gRingLatitude: 50.930738, - gRingLongitude: -120.19043 - }, - { - gRingLatitude: 50.930738, - gRingLongitude: -121.904297 - } - ] - } - ] - } - ] - }, - temporalCoverage: { - rangeOfDates: { - beginDate: { - calendarDate: '2023-01-02' - }, - endDate: { - calendarDate: '2023-01-30' - } - } - }, - taxonomicCoverage: { - taxonomicClassification: [ - { - taxonRankName: 'SPECIES', - taxonRankValue: 'Alces americanus', - commonNames: 'Moose', - taxonId: { - $: { - provider: '' - }, - _: '2065' - } - } - ] - } - } - }, - designDescription: { - description: { - section: [ - { - title: 'Field Method', - para: 'Call Playback' - }, - { - title: 'Vantage Codes', - para: { - itemizedlist: { - listitem: [ - { - para: 'Aerial' - } - ] - } - } - } - ] - } - } - } - ]); - - const emlPackage = await emlService.buildProjectEmlPackage({ projectId: 1 }); - - expect(emlPackage.toString()).to.equal( - `Project NameA Rocha CanadaEMAIL@address.com2023-03-13EnglishFirst NameLast NameA Rocha CanadaEMAIL@address.comProject NameFirst NameLast NameA Rocha CanadaEMAIL@address.compointOfContact
ObjectivesObjectives
Agency NameBC Hydro
Funding Agency Project IDAGENCY PROJECT ID
Investment Action/CategoryNot Applicable
Funding Amount123456789
Funding Start Date2023-01-02
Funding End Date2023-01-30
Location Description-121.904297-120.1904351.97134650.93073850.930738-121.90429751.971346-121.90429751.971346-120.1904350.930738-120.1904350.930738-121.9042972023-01-012023-01-31Survey NameFirst NameLast NamepointOfContact
Intended OutcomesHabitat Assessment
Additional DetailsAdditional Details
Agency NameBC Hydro
Funding Agency Project IDAGENCY PROJECT ID
Investment Action/CategoryNot Applicable
Funding Amount123456789
Funding Start Date2023-01-02
Funding End Date2023-01-30
Survey Area Name-121.904297-120.1904351.97134650.93073850.930738-121.90429751.971346-121.90429751.971346-120.1904350.930738-120.1904350.930738-121.9042972023-01-022023-01-30SPECIESAlces americanusMoose2065
Field MethodCall Playback
Ecological SeasonSpring
Vantage CodesAerial
1116c94a-8cd5-480d-a1f3-dac794e57c05Aquatic Habitat1116c94a-8cd5-480d-a1f3-dac794e57c05Habitat Protection1116c94a-8cd5-480d-a1f3-dac794e57c05Awareness RaisingOutreach & CommunicationsReported and social media1116c94a-8cd5-480d-a1f3-dac794e57c05BC Hydro1116c94a-8cd5-480d-a1f3-dac794e57c05Acho Dene Koe First Nation
` - ); - }); - }); - - describe('buildSurveyEmlPackage', () => { - it('should build an EML package for a survey successfully', async () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - sinon - .stub(ProjectService.prototype, 'getProjectById') - .resolves({ project: { uuid: '1116c94a-8cd5-480d-a1f3-dac794e57c05' } } as IGetProject); - - sinon - .stub(SurveyService.prototype, 'getSurveyById') - .resolves({ survey_details: { uuid: '69b506d1-3a50-4a39-b4c7-190bd0b34b9' } } as SurveyObject); - - // Build EML section - sinon.stub(EmlService.prototype, '_buildEmlSection').returns({ - $: { - packageId: 'urn:uuid:1116c94a-8cd5-480d-a1f3-dac794e57c05', - system: '', - 'xmlns:eml': 'https://eml.ecoinformatics.org/eml-2.2.0', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xmlns:stmml': 'http://www.xml-cml.org/schema/schema24', - 'xsi:schemaLocation': 'https://eml.ecoinformatics.org/eml-2.2.0 xsd/eml.xsd' - } - }); - - // Build dataset EML section - sinon.stub(EmlService.prototype, '_buildSurveyEmlDatasetSection').returns({ - $: { - system: '', - id: '1116c94a-8cd5-480d-a1f3-dac794e57c05' - }, - title: 'Survey Name', - creator: { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - } - }, - pubDate: '2023-03-13', - language: 'English', - contact: { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - } - } - }); - - // Build Project EML section - sinon.stub(EmlService.prototype, '_buildSurveyEmlProjectSection').resolves({ - $: { - id: '69b506d1-3a50-4a39-b4c7-190bd0b34b96', - system: '' - }, - title: 'Survey Name', - personnel: [ - { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - role: 'pointOfContact' - } - ], - abstract: { - section: [ - { - title: 'Intended Outcomes', - para: 'Habitat Assessment' - }, - { - title: 'Additional Details', - para: 'Additional Details' - } - ] - }, - studyAreaDescription: { - coverage: { - geographicCoverage: { - geographicDescription: 'Survey Area Name', - boundingCoordinates: { - westBoundingCoordinate: -121.904297, - eastBoundingCoordinate: -120.19043, - northBoundingCoordinate: 51.971346, - southBoundingCoordinate: 50.930738 - }, - datasetGPolygon: [ - { - datasetGPolygonOuterGRing: [ - { - gRingPoint: [ - { - gRingLatitude: 50.930738, - gRingLongitude: -121.904297 - }, - { - gRingLatitude: 51.971346, - gRingLongitude: -121.904297 - }, - { - gRingLatitude: 51.971346, - gRingLongitude: -120.19043 - }, - { - gRingLatitude: 50.930738, - gRingLongitude: -120.19043 - }, - { - gRingLatitude: 50.930738, - gRingLongitude: -121.904297 - } - ] - } - ] - } - ] - }, - temporalCoverage: { - rangeOfDates: { - beginDate: { - calendarDate: '2023-01-02' - }, - endDate: { - calendarDate: '2023-01-30' - } - } - }, - taxonomicCoverage: { - taxonomicClassification: [ - { - taxonRankName: 'SPECIES', - taxonRankValue: 'Alces americanus', - commonNames: 'Moose', - taxonId: { - $: { - provider: '' - }, - _: '2065' - } - } - ] - } - } - }, - designDescription: { - description: { - section: [ - { - title: 'Field Method', - para: 'Call Playback' - }, - { - title: 'Ecological Season', - para: 'Spring' - }, - { - title: 'Vantage Codes', - para: { - itemizedlist: { - listitem: [ - { - para: 'Aerial' - } - ] - } - } - } - ] - } - } - }); - - // Build Project additional metadata - sinon.stub(EmlService.prototype, '_getProjectAdditionalMetadata').resolves([ - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { projectTypes: { projectType: 'Aquatic Habitat' } } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - projectActivities: { - projectActivity: [{ name: 'Habitat Protection' }] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - IUCNConservationActions: { - IUCNConservationAction: [ - { - IUCNConservationActionLevel1Classification: 'Awareness Raising', - IUCNConservationActionLevel2SubClassification: 'Outreach & Communications', - IUCNConservationActionLevel3SubClassification: 'Reported and social media' - } - ] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - stakeholderPartnerships: { - stakeholderPartnership: [{ name: 'BC Hydro' }] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - firstNationPartnerships: { - firstNationPartnership: [{ name: 'Acho Dene Koe First Nation' }] - } - } - } - ]); - - // Build survey additional metadata - sinon.stub(EmlService.prototype, '_getSurveyAdditionalMetadata').resolves([]); - - // Build related project section - sinon.stub(EmlService.prototype, '_buildProjectEmlProjectSection').returns({ - $: { - id: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - system: '' - }, - title: 'Project Name', - personnel: [ - { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ], - abstract: { - section: [ - { - title: 'Objectives', - para: 'Objectives' - } - ] - }, - studyAreaDescription: { - coverage: { - geographicCoverage: { - geographicDescription: 'Location Description', - boundingCoordinates: { - westBoundingCoordinate: -121.904297, - eastBoundingCoordinate: -120.19043, - northBoundingCoordinate: 51.971346, - southBoundingCoordinate: 50.930738 - }, - datasetGPolygon: [ - { - datasetGPolygonOuterGRing: [ - { - gRingPoint: [ - { - gRingLatitude: 50.930738, - gRingLongitude: -121.904297 - }, - { - gRingLatitude: 51.971346, - gRingLongitude: -121.904297 - }, - { - gRingLatitude: 51.971346, - gRingLongitude: -120.19043 - }, - { - gRingLatitude: 50.930738, - gRingLongitude: -120.19043 - }, - { - gRingLatitude: 50.930738, - gRingLongitude: -121.904297 - } - ] - } - ] - } - ] - }, - temporalCoverage: { - rangeOfDates: { - beginDate: { - calendarDate: '2023-01-01' - }, - endDate: { - calendarDate: '2023-01-31' - } - } - } - } - } - }); - - const emlPackage = await emlService.buildSurveyEmlPackage({ surveyId: 1 }); - expect(emlPackage.toString()).to.equal( - `Survey NameFirst NameLast Name2023-03-13EnglishFirst NameLast NameSurvey NameFirst NameLast NamepointOfContact
Intended OutcomesHabitat Assessment
Additional DetailsAdditional Details
Agency NameBC Hydro
Funding Agency Project IDAGENCY PROJECT ID
Investment Action/CategoryNot Applicable
Funding Amount123456789
Funding Start Date2023-01-02
Funding End Date2023-01-30
Survey Area Name-121.904297-120.1904351.97134650.93073850.930738-121.90429751.971346-121.90429751.971346-120.1904350.930738-120.1904350.930738-121.9042972023-01-022023-01-30SPECIESAlces americanusMoose2065
Field MethodCall Playback
Ecological SeasonSpring
Vantage CodesAerial
Project NameFirst NameLast NameA Rocha CanadaEMAIL@address.compointOfContact
ObjectivesObjectives
Agency NameBC Hydro
Funding Agency Project IDAGENCY PROJECT ID
Investment Action/CategoryNot Applicable
Funding Amount123456789
Funding Start Date2023-01-02
Funding End Date2023-01-30
Location Description-121.904297-120.1904351.97134650.93073850.930738-121.90429751.971346-121.90429751.971346-120.1904350.930738-120.1904350.930738-121.9042972023-01-012023-01-31
1116c94a-8cd5-480d-a1f3-dac794e57c05Aquatic Habitat1116c94a-8cd5-480d-a1f3-dac794e57c05Habitat Protection1116c94a-8cd5-480d-a1f3-dac794e57c05Awareness RaisingOutreach & CommunicationsReported and social media1116c94a-8cd5-480d-a1f3-dac794e57c05BC Hydro1116c94a-8cd5-480d-a1f3-dac794e57c05Acho Dene Koe First Nation
` - ); - }); - }); - - describe('codes', () => { - const mockAllCodesResponse = { - management_action_type: [], - first_nations: [], - agency: [], - investment_action_category: [], - type: [], - program: [], - proprietor_type: [], - iucn_conservation_action_level_1_classification: [], - iucn_conservation_action_level_2_subclassification: [], - iucn_conservation_action_level_3_subclassification: [], - system_roles: [], - project_roles: [], - administrative_activity_status_type: [], - intended_outcomes: [], - vantage_codes: [], - site_selection_strategies: [], - survey_jobs: [], - sample_methods: [], - survey_progress: [], - method_response_metrics: [] - }; - - it('should retrieve codes if _codes is undefined', async () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const codeStub = sinon.stub(CodeService.prototype, 'getAllCodeSets').resolves(mockAllCodesResponse); - - const codes = await emlService.codes(); - - expect(emlService._codes).to.eql(mockAllCodesResponse); - expect(emlService._codes).to.eql(codes); - expect(codeStub).to.be.calledOnce; - }); - - it('should return cached codes if _codes is not undefined', async () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - emlService._codes = mockAllCodesResponse; - - const codeStub = sinon.stub(CodeService.prototype, 'getAllCodeSets'); - - const codes = await emlService.codes(); - - expect(emlService._codes).to.eql(mockAllCodesResponse); - expect(emlService._codes).to.eql(codes); - expect(codeStub).not.to.be.called; - }); - - it('should return cached codes upon subsequent calls', async () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const codeStub = sinon.stub(CodeService.prototype, 'getAllCodeSets').resolves(mockAllCodesResponse); - - const freshCodes = await emlService.codes(); - const cachedCodes = await emlService.codes(); - - expect(freshCodes).to.eql(cachedCodes); - expect(codeStub).to.be.calledOnce; - }); - }); - - describe('loadEmlDbConstants', () => { - beforeEach(() => { - sinon.restore(); - }); - - it('should yield Not Supplied constants if the database returns no rows', async () => { - const mockQuery = sinon.stub(); - - mockQuery.onCall(0).resolves({ rowCount: 0, rows: [] }); - mockQuery.onCall(1).resolves({ rowCount: 0, rows: [] }); - mockQuery.onCall(2).resolves({ rowCount: 0, rows: [] }); - mockQuery.onCall(3).resolves({ rowCount: 0, rows: [] }); - mockQuery.onCall(4).resolves({ rowCount: 0, rows: [] }); - mockQuery.onCall(5).resolves({ rowCount: 0, rows: [] }); - - const mockDBConnection = { - ...getMockDBConnection(), - sql: mockQuery - }; - - const emlService = new EmlService(mockDBConnection); - - await emlService.loadEmlDbConstants(); - - expect(emlService._constants).to.eql({ - EML_VERSION: '1.0.0', - EML_PROVIDER_URL: 'Not Supplied', - EML_SECURITY_PROVIDER_URL: 'Not Supplied', - EML_ORGANIZATION_NAME: 'Not Supplied', - EML_ORGANIZATION_URL: 'Not Supplied', - EML_TAXONOMIC_PROVIDER_URL: 'Not Supplied', - EML_INTELLECTUAL_RIGHTS: 'Not Supplied' - }); - }); - - it('should yield Not Supplied constants if the database returns null constants', async () => { - const mockQuery = sinon.stub(); - - mockQuery.onCall(0).resolves({ rowCount: 0, rows: [{ constant: null }] }); - mockQuery.onCall(1).resolves({ rowCount: 0, rows: [{ constant: null }] }); - mockQuery.onCall(2).resolves({ rowCount: 0, rows: [{ constant: null }] }); - mockQuery.onCall(3).resolves({ rowCount: 0, rows: [{ constant: null }] }); - mockQuery.onCall(4).resolves({ rowCount: 0, rows: [{ constant: null }] }); - mockQuery.onCall(5).resolves({ rowCount: 0, rows: [{ constant: null }] }); - - const mockDBConnection = { - ...getMockDBConnection(), - sql: mockQuery - }; - - const emlService = new EmlService(mockDBConnection); - - await emlService.loadEmlDbConstants(); - - expect(emlService._constants).to.eql({ - EML_VERSION: '1.0.0', - EML_PROVIDER_URL: 'Not Supplied', - EML_SECURITY_PROVIDER_URL: 'Not Supplied', - EML_ORGANIZATION_NAME: 'Not Supplied', - EML_ORGANIZATION_URL: 'Not Supplied', - EML_TAXONOMIC_PROVIDER_URL: 'Not Supplied', - EML_INTELLECTUAL_RIGHTS: 'Not Supplied' - }); - }); - - it('should fetch DB constants successfully', async () => { - const mockQuery = sinon.stub(); - - mockQuery.onCall(0).resolves({ rowCount: 1, rows: [{ constant: 'test-org-url' }] }); - mockQuery.onCall(1).resolves({ rowCount: 1, rows: [{ constant: 'test-org-name' }] }); - mockQuery.onCall(2).resolves({ rowCount: 1, rows: [{ constant: 'test-provider-url' }] }); - mockQuery.onCall(3).resolves({ rowCount: 1, rows: [{ constant: 'test-security-provider' }] }); - mockQuery.onCall(4).resolves({ rowCount: 1, rows: [{ constant: 'test-int-rights' }] }); - mockQuery.onCall(5).resolves({ rowCount: 1, rows: [{ constant: 'test-taxon-url' }] }); - - const mockDBConnection = { - ...getMockDBConnection(), - sql: mockQuery - }; - - const emlService = new EmlService(mockDBConnection); - - await emlService.loadEmlDbConstants(); - - expect(emlService._constants).to.eql({ - EML_VERSION: '1.0.0', - EML_ORGANIZATION_URL: 'test-org-url', - EML_ORGANIZATION_NAME: 'test-org-name', - EML_PROVIDER_URL: 'test-provider-url', - EML_SECURITY_PROVIDER_URL: 'test-security-provider', - EML_INTELLECTUAL_RIGHTS: 'test-int-rights', - EML_TAXONOMIC_PROVIDER_URL: 'test-taxon-url' - }); - }); - }); - - describe('_buildEmlSection', () => { - it('should build an EML section', () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const response = emlService._buildEmlSection('aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii'); - expect(response).to.eql({ - $: { - packageId: 'urn:uuid:aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', - system: '', - 'xmlns:eml': 'https://eml.ecoinformatics.org/eml-2.2.0', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xmlns:stmml': 'http://www.xml-cml.org/schema/schema24', - 'xsi:schemaLocation': 'https://eml.ecoinformatics.org/eml-2.2.0 xsd/eml.xsd' - } - }); - }); - }); - - describe('_buildProjectEmlDatasetSection', () => { - it('should build an EML dataset section', () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const mockOrg = { - organizationName: 'Test Organization', - electronicMailAddress: 'EMAIL@address.com' - }; - - const mockPackageId = 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii'; - const mockProjectData = { - project: { - project_name: 'Test Project Name' - } - } as IGetProject; - - sinon.stub(EmlService.prototype, '_getProjectDatasetCreator').returns(mockOrg); - - sinon.stub(EmlService.prototype, '_makeEmlDateString').returns('2023-01-01'); - - sinon.stub(EmlService.prototype, '_getProjectContact').returns({ - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - ...mockOrg - }); - - const response = emlService._buildProjectEmlDatasetSection(mockPackageId, mockProjectData); - - expect(response).to.eql({ - $: { system: '', id: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii' }, - title: 'Test Project Name', - creator: { - organizationName: 'Test Organization', - electronicMailAddress: 'EMAIL@address.com' - }, - pubDate: '2023-01-01', - language: 'English', - contact: { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - organizationName: 'Test Organization', - electronicMailAddress: 'EMAIL@address.com' - } - }); - }); - }); - - describe('_buildProjectEmlProjectSection', () => { - it('should build a project EML Project section', () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const mockProjectData = { - project: { - uuid: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', - project_name: 'Test Project Name' - }, - objectives: { - objectives: 'Project objectives.' - } - } as IGetProject; - - sinon.stub(EmlService.prototype, '_getProjectPersonnel').returns([ - { - individualName: { givenName: 'First Name', surName: 'Last Name' }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ]); - - sinon.stub(EmlService.prototype, '_getProjectTemporalCoverage').returns({ - rangeOfDates: { - beginDate: { calendarDate: '2023-01-01' }, - endDate: { calendarDate: '2023-01-31' } - } - }); - - const response = emlService._buildProjectEmlProjectSection(mockProjectData, []); - - expect(response).to.eql({ - $: { id: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', system: '' }, - title: 'Test Project Name', - personnel: [ - { - individualName: { givenName: 'First Name', surName: 'Last Name' }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ], - abstract: { - section: [{ title: 'Objectives', para: 'Project objectives.' }] - }, - studyAreaDescription: { - coverage: { - geographicCoverage: { - geographicDescription: 'Location Description', - boundingCoordinates: { - westBoundingCoordinate: -121.904297, - eastBoundingCoordinate: -120.19043, - northBoundingCoordinate: 51.971346, - southBoundingCoordinate: 50.930738 - }, - datasetGPolygon: [ - { - datasetGPolygonOuterGRing: [ - { - gRingPoint: [ - { gRingLatitude: 50.930738, gRingLongitude: -121.904297 }, - { gRingLatitude: 51.971346, gRingLongitude: -121.904297 }, - { gRingLatitude: 51.971346, gRingLongitude: -120.19043 }, - { gRingLatitude: 50.930738, gRingLongitude: -120.19043 }, - { gRingLatitude: 50.930738, gRingLongitude: -121.904297 } - ] - } - ] - } - ] - }, - temporalCoverage: { - rangeOfDates: { - beginDate: { calendarDate: '2023-01-01' }, - endDate: { calendarDate: '2023-01-31' } - } - } - } - } - }); - }); - - it('should build if optional parameters are missing', () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const mockProjectData = { - project: { - uuid: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', - project_name: 'Test Project Name' - }, - objectives: { - objectives: 'Project objectives.' - } - } as IGetProject; - - sinon.stub(EmlService.prototype, '_getProjectPersonnel').returns([ - { - individualName: { givenName: 'First Name', surName: 'Last Name' }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ]); - - sinon.stub(EmlService.prototype, '_getProjectTemporalCoverage').returns({ - rangeOfDates: { - beginDate: { calendarDate: '2023-01-01' }, - endDate: { calendarDate: '2023-01-31' } - } - }); - - const response = emlService._buildProjectEmlProjectSection(mockProjectData, []); - - expect(response).to.eql({ - $: { id: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', system: '' }, - title: 'Test Project Name', - personnel: [ - { - individualName: { givenName: 'First Name', surName: 'Last Name' }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ], - abstract: { - section: [{ title: 'Objectives', para: 'Project objectives.' }] - }, - studyAreaDescription: { - coverage: { - temporalCoverage: { - rangeOfDates: { - beginDate: { calendarDate: '2023-01-01' }, - endDate: { calendarDate: '2023-01-31' } - } - } - } - } - }); - }); - }); - - describe('_getSurveyAdditionalMetadata', async () => { - it('should return an empty array, since there is (currently) no additional metadata for surveys', async () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const additionalMeta = await emlService._getSurveyAdditionalMetadata([]); - - expect(additionalMeta).to.eql([]); - }); - }); - - describe('_getProjectAdditionalMetadata', () => { - // TODO - }); - - describe('_getProjectDatasetCreator', () => { - // TODO - }); - - describe('_getProjectContact', () => { - // TODO - }); - - describe('_getProjectPersonnel', () => { - // TODO - }); - - describe('_getSurveyPersonnel', () => { - it('should return survey personnel', async () => { - // TODO: Replace this test once SIMSBIOHUB-275 is merged. - /* - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const mockSurveyData = { - survey_details: { - biologist_first_name: 'biologist-fname', - biologist_last_name: 'biologist-lname' - } - } as SurveyObject; - - const response = emlService._getSurveyPersonnel(mockSurveyData); - - expect(response).to.eql([ - { - individualName: { givenName: 'biologist-fname', surName: 'biologist-lname' }, - role: 'pointOfContact' - } - ]); - */ - }); - }); - - describe('_getProjectTemporalCoverage', () => { - // - }); - - describe('_getSurveyTemporalCoverage', () => { - // - }); - - describe('_makeEmlDateString', () => { - // - }); - - describe('_makePolygonFeatures', () => { - // - }); - - describe('_makeDatasetGPolygons', () => { - // - }); - - describe('_getProjectGeographicCoverage', () => { - // - }); - - describe('_getSurveyGeographicCoverage', () => { - // - }); - - describe('_getSurveyFocalTaxonomicCoverage', () => { - // - }); - - describe('_getSurveyDesignDescription', () => { - // - }); - - describe('_buildAllSurveyEmlProjectSections', () => { - // - }); - - describe('_buildSurveyEmlProjectSection', () => { - // - }); -}); diff --git a/api/src/services/eml-service.ts b/api/src/services/eml-service.ts deleted file mode 100644 index a27892e960..0000000000 --- a/api/src/services/eml-service.ts +++ /dev/null @@ -1,1015 +0,0 @@ -import bbox from '@turf/bbox'; -import circle from '@turf/circle'; -import { AllGeoJSON, featureCollection } from '@turf/helpers'; -import { coordEach } from '@turf/meta'; -import jsonpatch from 'fast-json-patch'; -import { Feature, GeoJsonProperties, Geometry } from 'geojson'; -import _ from 'lodash'; -import SQL from 'sql-template-strings'; -import xml2js from 'xml2js'; -import { PROJECT_ROLE } from '../constants/roles'; -import { IDBConnection } from '../database/db'; -import { IGetProject } from '../models/project-view'; -import { SurveyObject } from '../models/survey-view'; -import { IAllCodeSets } from '../repositories/code-repository'; -import { CodeService } from './code-service'; -import { DBService } from './db-service'; -import { ProjectService } from './project-service'; -import { SurveyService } from './survey-service'; - -const NOT_SUPPLIED = 'Not Supplied'; -const EMPTY_STRING = ``; - -const DEFAULT_DB_CONSTANTS = { - EML_VERSION: '1.0.0', - EML_PROVIDER_URL: NOT_SUPPLIED, - EML_SECURITY_PROVIDER_URL: NOT_SUPPLIED, - EML_ORGANIZATION_NAME: NOT_SUPPLIED, - EML_ORGANIZATION_URL: NOT_SUPPLIED, - EML_TAXONOMIC_PROVIDER_URL: NOT_SUPPLIED, - EML_INTELLECTUAL_RIGHTS: NOT_SUPPLIED -}; - -type EmlDbConstants = { - EML_VERSION: string; - EML_PROVIDER_URL: string; - EML_SECURITY_PROVIDER_URL: string; - EML_ORGANIZATION_NAME: string; - EML_ORGANIZATION_URL: string; - EML_TAXONOMIC_PROVIDER_URL: string; - EML_INTELLECTUAL_RIGHTS: string; -}; - -type BuildProjectEmlOptions = { - projectId: number; -}; - -type BuildSurveyEmlOptions = { - surveyId: number; -}; - -type AdditionalMetadata = { - describes: string; - metadata: Record; -}; - -type EmlPackageOptions = { - packageId: string; -}; - -/** - * Represents an EML package used to produce an EML string - * - * @class EmlPackage - */ -export class EmlPackage { - /** - * The unique identifier representing the EML package - * - * @type {string} - * @memberof EmlPackage - */ - packageId: string; - - /** - * Maintains all EML package fields - * - * @type {Record} - * @memberof EmlPackage - */ - _data: Record = {}; - - /** - * Maintains EML field data for the EML package - * - * @type {(Record | null)} - * @memberof EmlPackage - */ - _emlMetadata: Record | null = null; - - /** - * Maintains Dataset EML data for the EML package - * - * @type {(Record | null)} - * @memberof EmlPackage - */ - _datasetMetadata: Record | null = null; - - /** - * Maintains Dataset Project EML data for the EML package - * - * @type {(Record | null)} - * @memberof EmlPackage - */ - _projectMetadata: Record | null = null; - - /** - * Maintains Related Projects fields for the EML package - * - * @type {Record[]} - * @memberof EmlPackage - */ - _relatedProjects: Record[] = []; - - /** - * Maintains Additional Metadata fields for the EML package - * - * @type {AdditionalMetadata[]} - * @memberof EmlPackage - */ - _additionalMetadata: AdditionalMetadata[] = []; - - /** - * The XML2JS Builder which builds the EML string - * - * @type {xml2js.Builder} - * @memberof EmlPackage - */ - _xml2jsBuilder: xml2js.Builder; - - constructor(options: EmlPackageOptions) { - this.packageId = options.packageId; - - this._xml2jsBuilder = new xml2js.Builder({ renderOpts: { pretty: false } }); - } - - /** - * Sets the EML data field for the EML package - * - * @param {Record} emlMetadata - * @return {*} - * @memberof EmlPackage - */ - withEml(emlMetadata: Record): this { - this._emlMetadata = emlMetadata; - - return this; - } - - /** - * Sets the Dataset data field for the EML package - * - * @param {Record} datasetMetadata - * @return {*} - * @memberof EmlPackage - */ - withDataset(datasetMetadata: Record): this { - this._datasetMetadata = datasetMetadata; - - return this; - } - - /** - * Sets the Dataset Project data field for the EML package - * - * @param {Record} projectMetadata - * @return {*} - * @memberof EmlPackage - */ - withProject(projectMetadata: Record): this { - this._projectMetadata = projectMetadata; - - return this; - } - - /** - * Appends Additional Metadata fields on the EML package - * - * @param {AdditionalMetadata[]} additionalMetadata - * @return {*} - * @memberof EmlPackage - */ - withAdditionalMetadata(additionalMetadata: AdditionalMetadata[]): this { - additionalMetadata.forEach((meta) => this._additionalMetadata.push(meta)); - - return this; - } - - /** - * Appends Related Project fields on the EML package - * - * @param {Record[]} relatedProjects - * @return {*} - * @memberof EmlPackage - */ - withRelatedProjects(relatedProjects: Record[]): this { - relatedProjects.forEach((project) => this._relatedProjects.push(project)); - - return this; - } - - /** - * Compiles the EML package - * - * @return {*} {EmlPackage} - * @memberof EmlPackage - */ - build(): this { - if (this._data) { - // Support subsequent compilations - this._data = {}; - } - - // Add project metadata to dataset - if (this._projectMetadata) { - if (!this._datasetMetadata) { - throw new Error("Can't build Project EML without first building dataset EML."); - } - - this._datasetMetadata.project = this._projectMetadata; - } - - // Add related projects metadata to project - if (this._relatedProjects.length) { - if (!this._datasetMetadata?.project) { - throw new Error("Can't build Project EML without first building Dataset Project EML."); - } else if (!this._datasetMetadata) { - throw new Error("Can't build Related Project EML without first building dataset EML."); - } - - this._datasetMetadata.project.relatedProject = this._relatedProjects; - } - - jsonpatch.applyOperation(this._data, { - op: 'add', - path: '/eml:eml', - value: this._emlMetadata - }); - - jsonpatch.applyOperation(this._data, { - op: 'add', - path: '/eml:eml/dataset', - value: this._datasetMetadata - }); - - jsonpatch.applyOperation(this._data, { - op: 'add', - path: '/eml:eml/additionalMetadata', - value: this._additionalMetadata - }); - - return this; - } - - /** - * Returns the EML package as an EML-compliant XML string. - * - * @return {*} {string} - * @memberof EmlPackage - */ - toString(): string { - return this._xml2jsBuilder.buildObject(this._data); - } - - /** - * Returns the EML as a JSON object - * - * @return {*} {Record} - * @memberof EmlPackage - */ - toJson(): Record { - return this._data; - } -} - -/** - * Service to produce Ecological Metadata Language (EML) data for projects and surveys. - * - * @see https://eml.ecoinformatics.org for EML specification - * @see https://knb.ecoinformatics.org/emlparser/ for an online EML validator. - * @export - * @class EmlService - * @extends {DBService} - */ -export class EmlService extends DBService { - _projectService: ProjectService; - _surveyService: SurveyService; - _codeService: CodeService; - - _constants: EmlDbConstants = DEFAULT_DB_CONSTANTS; - - _codes: IAllCodeSets | null; - - constructor(connection: IDBConnection) { - super(connection); - - this._projectService = new ProjectService(this.connection); - this._surveyService = new SurveyService(this.connection); - this._codeService = new CodeService(this.connection); - this._codes = null; - } - - /** - * Produces an EML package representing the project with the given project ID - * - * @param {BuildProjectEmlOptions} options - * @return {*} {Promise} - * @memberof EmlService - */ - async buildProjectEmlPackage(options: BuildProjectEmlOptions): Promise { - const { projectId } = options; - await this.loadEmlDbConstants(); - - const projectData = await this._projectService.getProjectById(projectId); - const packageId = projectData.project.uuid; - - const surveysData = await this._surveyService.getSurveysByProjectId(projectId); - - const emlPackage = new EmlPackage({ packageId }); - - return ( - emlPackage - // Build EML field - .withEml(this._buildEmlSection(packageId)) - - // Build EML->Dataset field - .withDataset(this._buildProjectEmlDatasetSection(packageId, projectData)) - - // Build EML->Dataset->Project field - .withProject(this._buildProjectEmlProjectSection(projectData, surveysData)) - - // Build EML->Dataset->Project->AdditionalMetadata field - .withAdditionalMetadata(await this._getProjectAdditionalMetadata(projectData)) - .withAdditionalMetadata(await this._getSurveyAdditionalMetadata(surveysData)) - - // Build EML->Dataset->Project->RelatedProject field - .withRelatedProjects(await this._buildAllSurveyEmlProjectSections(surveysData)) - - // Compile the EML package - .build() - ); - } - - /** - * Produces an EML package representing the survey with the given survey ID - * - * @param {BuildSurveyEmlOptions} options - * @return {*} {Promise} - * @memberof EmlService - */ - async buildSurveyEmlPackage(options: BuildSurveyEmlOptions): Promise { - const { surveyId } = options; - await this.loadEmlDbConstants(); - - const surveyData = await this._surveyService.getSurveyById(surveyId); - - const packageId = surveyData.survey_details.uuid; - - const projectId = surveyData.survey_details.project_id; - const projectData = await this._projectService.getProjectById(projectId); - - const emlPackage = new EmlPackage({ packageId }); - - return ( - emlPackage - // Build EML field - .withEml(this._buildEmlSection(packageId)) - - // Build EML->Dataset field - .withDataset(this._buildSurveyEmlDatasetSection(packageId, surveyData)) - - // Build EML->Dataset->Project field - .withProject(await this._buildSurveyEmlProjectSection(surveyData)) - - // Build EML->Dataset->Project->AdditionalMetadata field - .withAdditionalMetadata(await this._getProjectAdditionalMetadata(projectData)) - .withAdditionalMetadata(await this._getSurveyAdditionalMetadata([surveyData])) - - // Build EML->Dataset->Project->RelatedProject field// - .withRelatedProjects([this._buildProjectEmlProjectSection(projectData, [surveyData])]) - - // Compile the EML package - .build() - ); - } - - /** - * Loads all codesets. - * - * @return {*} {Promise} - * @memberof EmlService - */ - async codes(): Promise { - if (!this._codes) { - this._codes = await this._codeService.getAllCodeSets(); - } - - return this._codes; - } - - /** - * Loads constants pertaining to EML generation from the database. - */ - async loadEmlDbConstants() { - const [ - organizationUrl, - organizationName, - providerURL, - securityProviderURL, - intellectualRights, - taxonomicProviderURL - ] = await Promise.all([ - this.connection.sql<{ constant: string }>( - SQL`SELECT api_get_character_system_metadata_constant(${'ORGANIZATION_URL'}) as constant;` - ), - this.connection.sql<{ constant: string }>( - SQL`SELECT api_get_character_system_metadata_constant(${'ORGANIZATION_NAME_FULL'}) as constant;` - ), - this.connection.sql<{ constant: string }>( - SQL`SELECT api_get_character_system_metadata_constant(${'PROVIDER_URL'}) as constant;` - ), - this.connection.sql<{ constant: string }>( - SQL`SELECT api_get_character_system_metadata_constant(${'SECURITY_PROVIDER_URL'}) as constant;` - ), - this.connection.sql<{ constant: string }>( - SQL`SELECT api_get_character_system_metadata_constant(${'INTELLECTUAL_RIGHTS'}) as constant;` - ), - this.connection.sql<{ constant: string }>( - SQL`SELECT api_get_character_system_metadata_constant(${'TAXONOMIC_PROVIDER_URL'}) as constant;` - ) - ]); - - this._constants.EML_ORGANIZATION_URL = organizationUrl.rows[0]?.constant || NOT_SUPPLIED; - this._constants.EML_ORGANIZATION_NAME = organizationName.rows[0]?.constant || NOT_SUPPLIED; - this._constants.EML_PROVIDER_URL = providerURL.rows[0]?.constant || NOT_SUPPLIED; - this._constants.EML_SECURITY_PROVIDER_URL = securityProviderURL.rows[0]?.constant || NOT_SUPPLIED; - this._constants.EML_INTELLECTUAL_RIGHTS = intellectualRights.rows[0]?.constant || NOT_SUPPLIED; - this._constants.EML_TAXONOMIC_PROVIDER_URL = taxonomicProviderURL.rows[0]?.constant || NOT_SUPPLIED; - } - - /** - * Builds the EML section of an EML package for either a project or survey - * - * @param {string} packageId - * @return {*} {Record} - * @memberof EmlService - */ - _buildEmlSection(packageId: string): Record { - return { - $: { - packageId: `urn:uuid:${packageId}`, - system: EMPTY_STRING, - 'xmlns:eml': 'https://eml.ecoinformatics.org/eml-2.2.0', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xmlns:stmml': 'http://www.xml-cml.org/schema/schema24', - 'xsi:schemaLocation': 'https://eml.ecoinformatics.org/eml-2.2.0 xsd/eml.xsd' - } - }; - } - - /** - * Builds the EML Dataset section for a project - * - * @param {IGetProject} projectData - * @param {string} packageId - * @return {*} {Promise>} - * @memberof EmlService - */ - _buildProjectEmlDatasetSection(packageId: string, projectData: IGetProject): Record { - return { - $: { system: EMPTY_STRING, id: packageId }, - title: projectData.project.project_name, - creator: this._getProjectDatasetCreator(projectData), - - // EML specification expects short ISO format - pubDate: this._makeEmlDateString(), - language: 'English', - contact: this._getProjectContact(projectData) - }; - } - - /** - * Builds the EML Dataset section for a survey - * - * @param {string} packageId - * @param {SurveyObject} surveyData - * @return {*} {Record} - * @memberof EmlService - */ - _buildSurveyEmlDatasetSection(packageId: string, surveyData: SurveyObject): Record { - return { - $: { system: EMPTY_STRING, id: packageId }, - title: surveyData.survey_details.survey_name, - creator: this._getSurveyContact(surveyData), - - // EML specification expects short ISO format - pubDate: this._makeEmlDateString(), - language: 'English', - contact: this._getSurveyContact(surveyData) - }; - } - - /** - * Builds the EML Project section for the given project data - * - * @param {IGetProject} projectData - * @return {*} {Record} - * @memberof EmlService - */ - _buildProjectEmlProjectSection(projectData: IGetProject, surveys: SurveyObject[]): Record { - return { - $: { id: projectData.project.uuid, system: EMPTY_STRING }, - title: projectData.project.project_name, - personnel: this._getProjectPersonnel(projectData), - abstract: { - section: [{ title: 'Objectives', para: projectData.objectives.objectives }] - }, - studyAreaDescription: { - coverage: { - ...this._getProjectGeographicCoverage(surveys), - temporalCoverage: this._getProjectTemporalCoverage(projectData) - } - } - }; - } - - /** - * Generates additional metadata fields for the given array of surveys - * - * @param {SurveyObjectWithAttachments[]} _surveysData - * @return {*} {AdditionalMetadata[]} - * @memberof EmlService - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async _getSurveyAdditionalMetadata(_surveysData: SurveyObject[]): Promise { - const additionalMetadata: AdditionalMetadata[] = []; - const codes = await this.codes(); - - await Promise.all( - _surveysData.map(async (item) => { - // add this metadata field so biohub is aware if EML is a project or survey - additionalMetadata.push({ - describes: item.survey_details.uuid, - metadata: { - types: { - type: 'SURVEY' - } - } - }); - - const partnetshipsMetadata = await this._buildPartnershipMetadata(item); - additionalMetadata.push(partnetshipsMetadata); - - if (item.survey_details.survey_types.length) { - const names = codes.type - .filter((code) => item.survey_details.survey_types.includes(code.id)) - .map((code) => code.name); - - additionalMetadata.push({ - describes: item.survey_details.uuid, - metadata: { - surveyTypes: { - surveyType: names.map((item) => { - return { name: item }; - }) - } - } - }); - } - }) - ); - - return additionalMetadata; - } - - /** - * Generates additional metadata fields for the given project - * - * @param {IGetProject} projectData - * @return {*} {Promise} - * @memberof EmlService - */ - async _getProjectAdditionalMetadata(projectData: IGetProject): Promise { - const additionalMetadata: AdditionalMetadata[] = []; - const codes = await this.codes(); - - if (projectData.project.project_programs) { - additionalMetadata.push({ - describes: projectData.project.uuid, - metadata: { - projectPrograms: { - projectProgram: projectData.project.project_programs.map( - (item) => codes.program.find((code) => code.id === item)?.name - ) - } - } - }); - } - - if (projectData.iucn.classificationDetails.length) { - const iucnNames = projectData.iucn.classificationDetails.map((iucnItem) => { - return { - level_1_name: codes.iucn_conservation_action_level_1_classification.find( - (code) => iucnItem.classification === code.id - )?.name, - level_2_name: codes.iucn_conservation_action_level_2_subclassification.find( - (code) => iucnItem.subClassification1 === code.id - )?.name, - level_3_name: codes.iucn_conservation_action_level_3_subclassification.find( - (code) => iucnItem.subClassification2 === code.id - )?.name - }; - }); - - additionalMetadata.push({ - describes: projectData.project.uuid, - metadata: { - IUCNConservationActions: { - IUCNConservationAction: iucnNames.map((item) => { - return { - IUCNConservationActionLevel1Classification: item.level_1_name, - IUCNConservationActionLevel2SubClassification: item.level_2_name, - IUCNConservationActionLevel3SubClassification: item.level_3_name - }; - }) - } - } - }); - } - - // add this metadata field so biohub is aware if EML is a project or survey - additionalMetadata.push({ - describes: projectData.project.uuid, - metadata: { - types: { - type: 'PROJECT' - } - } - }); - - return additionalMetadata; - } - - async _buildPartnershipMetadata(surveyData: SurveyObject): Promise { - const stakeholders = surveyData.partnerships.stakeholder_partnerships; - const codes = await this.codes(); - const indigenousPartnerships = surveyData.partnerships.indigenous_partnerships; - const firstNationsNames = codes.first_nations - .filter((code) => indigenousPartnerships.includes(code.id)) - .map((code) => code.name); - - const sortedPartnerships = _.sortBy([...firstNationsNames, ...stakeholders]); - - return { - describes: surveyData.survey_details.uuid, - metadata: { - partnerships: { - partnership: sortedPartnerships.map((name) => { - return { name }; - }) - } - } - }; - } - - /** - * Creates an object representing the dataset creator from the given projectData. - * - * - * @param {IGetProject} projectData - * @return {*} {Record} - * @memberof EmlService - */ - _getProjectDatasetCreator(projectData: IGetProject): Record { - const coordinator = projectData.participants.find((participant) => { - return participant.role_names.includes(PROJECT_ROLE.COORDINATOR); - }); - - if (!coordinator) { - // Return default organization name - return { organizationName: this._constants.EML_ORGANIZATION_NAME }; - } - - return { - individualName: { givenName: coordinator.given_name, surName: coordinator.family_name }, - electronicMailAddress: coordinator.email - }; - } - - /** - * Creates an object representing the primary contact for the given project. - * - * - * @param {IGetProject} projectData - * @return {*} {Record} - * @memberof EmlService - */ - _getProjectContact(projectData: IGetProject): Record { - const coordinator = projectData.participants.find((participant) => { - return participant.role_names.includes(PROJECT_ROLE.COORDINATOR); - }); - - if (!coordinator) { - // Return default organization name - return { organizationName: this._constants.EML_ORGANIZATION_NAME }; - } - - return { - individualName: { givenName: coordinator.given_name, surName: coordinator.family_name }, - electronicMailAddress: coordinator.email, - role: 'pointOfContact' - }; - } - - /** - * Creates an object representing the biologist name for the given survey. - * - * @param {SurveyObject} surveyData - * @return {*} {Record} - * @memberof EmlService - */ - _getSurveyContact(surveyData: SurveyObject): Record { - const coordinator = surveyData.participants.find((participant) => { - return participant.role_names.includes(PROJECT_ROLE.COORDINATOR); - }); - - if (!coordinator) { - // Return default organization name - return { organizationName: this._constants.EML_ORGANIZATION_NAME }; - } - - return { - individualName: { givenName: coordinator.given_name, surName: coordinator.family_name }, - electronicMailAddress: coordinator.email, - role: 'pointOfContact' - }; - } - - /** - * Creates an object representing all contacts for the given project. - * - * - * @param {IGetProject} projectData - * @return {*} {Record[]} - * @memberof EmlService - */ - _getProjectPersonnel(projectData: IGetProject): Record[] { - const participants = projectData.participants; - - return participants.map((participant) => ({ - individualName: { givenName: participant.given_name, surName: participant.family_name }, - electronicMailAddress: participant.email - })); - } - - /** - * Creates an object representing all contacts for the given survey. - * - * @param {SurveyObject} surveyData - * @return {*} {Record[]} - * @memberof EmlService - */ - _getSurveyPersonnel(surveyData: SurveyObject): Record[] { - const participants = surveyData.participants; - - return participants.map((participant) => ({ - individualName: { givenName: participant.given_name, surName: participant.family_name }, - electronicMailAddress: participant.email - })); - } - - /** - * Creates an object representing temporal coverage for the given project - * - * @param {IGetProject} projectData - * @return {*} {Record} - * @memberof EmlService - */ - _getProjectTemporalCoverage(projectData: IGetProject): Record { - if (!projectData.project.end_date) { - return { - singleDateTime: { - calendarDate: projectData.project.start_date - } - }; - } - - return { - rangeOfDates: { - beginDate: { calendarDate: projectData.project.start_date }, - endDate: { calendarDate: projectData.project.end_date } - } - }; - } - - /** - * Creates an object representing temporal coverage for the given survey - * - * @param {SurveyObject} surveyData - * @return {*} {Record} - * @memberof EmlService - */ - _getSurveyTemporalCoverage(surveyData: SurveyObject): Record { - if (!surveyData.survey_details.end_date) { - return { - singleDateTime: { - calendarDate: surveyData.survey_details.start_date - } - }; - } - - return { - rangeOfDates: { - beginDate: { calendarDate: surveyData.survey_details.start_date }, - endDate: { calendarDate: surveyData.survey_details.end_date } - } - }; - } - - /** - * Converts a Date or string into a date string compatible with EML. - * - * @param {Date | string} [date] - * @return {*} {string} - * @memberof EmlService - */ - _makeEmlDateString(date?: Date | string): string { - return (date ? new Date(date) : new Date()).toISOString().split('T')[0]; - } - - /** - * Creates an array of polygon features for the given project or survey geometry. - * - * @param {Feature[]} geometry - * @return {*} {Feature[]} - * @memberof EmlService - */ - _makePolygonFeatures(geometry: Feature[]): Feature[] { - return geometry.map((feature) => { - if (feature.geometry.type === 'Point' && feature.properties?.radius) { - return circle(feature.geometry, feature.properties.radius, { units: 'meters' }); - } - - return feature; - }); - } - - /** - * Creates a set of datasetGPoloygons for the given project or survey - * - * @param {Feature[]} polygonFeatures - * @return {*} {Record[]} - * @memberof EmlService - */ - _makeDatasetGPolygons(polygonFeatures: Feature[]): Record[] { - return polygonFeatures.map((feature) => { - const featureCoords: number[][] = []; - - coordEach(feature as AllGeoJSON, (currentCoord) => { - featureCoords.push(currentCoord); - }); - - return { - datasetGPolygonOuterGRing: [ - { - gRingPoint: featureCoords.map((coords) => { - return { gRingLatitude: coords[1], gRingLongitude: coords[0] }; - }) - } - ] - }; - }); - } - - _getBoundingBoxForFeatures(description: string, features: Feature[]): Record { - const polygonFeatures = this._makePolygonFeatures(features); - const datasetPolygons = this._makeDatasetGPolygons(polygonFeatures); - const boundingBox = bbox(featureCollection(polygonFeatures)); - - return { - geographicCoverage: { - geographicDescription: description, - boundingCoordinates: { - westBoundingCoordinate: boundingBox[0], - eastBoundingCoordinate: boundingBox[2], - northBoundingCoordinate: boundingBox[3], - southBoundingCoordinate: boundingBox[1] - }, - datasetGPolygon: datasetPolygons - } - }; - } - - /** - * Creates an object representing geographic coverage pertaining to the given survey - * - * @param {SurveyObject} surveyData - * @return {*} {Record} - * @memberof EmlService - */ - _getSurveyGeographicCoverage(surveyData: SurveyObject): Record { - if (!surveyData.locations?.length) { - return {}; - } - - let features: Feature[] = []; - - for (const item of surveyData.locations) { - features = features.concat(item.geometry as Feature[]); - } - - return this._getBoundingBoxForFeatures('Survey location Geographic Coverage', features); - } - - /** - * Creates an object representing geographic coverage pertaining to the given project - * - * @param {IGetProject} projectData - * @return {*} {Record} - * @memberof EmlService - */ - _getProjectGeographicCoverage(surveys: SurveyObject[]): Record { - if (!surveys.length) { - return {}; - } - let features: Feature[] = []; - - for (const survey of surveys) { - for (const location of survey.locations) { - features = features.concat(location.geometry as Feature[]); - } - } - - return this._getBoundingBoxForFeatures('Geographic coverage of all underlying project surveys', features); - } - - /** - * Creates an object representing the design description for the given survey - * - * @param {SurveyObject} surveyData - * @return {*} {Promise>} - * @memberof EmlService - */ - async _getSurveyDesignDescription(survey: SurveyObject): Promise> { - const codes = await this.codes(); - - return { - description: { - section: [ - { - title: 'Vantage Codes', - para: { - itemizedlist: { - listitem: codes.vantage_codes - .filter((code) => survey.purpose_and_methodology.vantage_code_ids.includes(code.id)) - .map((code) => { - return { para: code.name }; - }) - } - } - } - ] - } - }; - } - - /** - * Builds the EML Project section for the given array of surveys - * - * @param {SurveyObjectWithAttachments[]} surveys - * @return {*} {Promise[]>} - * @memberof EmlService - */ - async _buildAllSurveyEmlProjectSections(surveysData: SurveyObject[]): Promise[]> { - return Promise.all(surveysData.map(async (survey) => await this._buildSurveyEmlProjectSection(survey))); - } - - /** - * Builds the EML Project section for the given survey - * - * @param {SurveyObject} surveyData - * @return {*} {Promise>} - * @memberof EmlService - */ - async _buildSurveyEmlProjectSection(surveyData: SurveyObject): Promise> { - const codes = await this.codes(); - - return { - $: { id: surveyData.survey_details.uuid, system: EMPTY_STRING }, - title: surveyData.survey_details.survey_name, - personnel: this._getSurveyPersonnel(surveyData), - abstract: { - section: [ - { - title: 'Intended Outcomes', - para: surveyData.purpose_and_methodology.intended_outcome_ids - .map((outcomeId) => codes.intended_outcomes.find((code) => code.id === outcomeId)?.name) - .join(', ') - }, - { - title: 'Additional Details', - para: surveyData.purpose_and_methodology.additional_details || NOT_SUPPLIED - } - ] - }, - studyAreaDescription: { - coverage: { - ...this._getSurveyGeographicCoverage(surveyData), - temporalCoverage: this._getSurveyTemporalCoverage(surveyData), - taxonomicCoverage: [] //await this._getSurveyFocalTaxonomicCoverage(surveyData) - } - }, - designDescription: await this._getSurveyDesignDescription(surveyData) - }; - } -} diff --git a/api/src/services/project-service.test.ts b/api/src/services/project-service.test.ts index 8c46a63625..577928b8dc 100644 --- a/api/src/services/project-service.test.ts +++ b/api/src/services/project-service.test.ts @@ -23,18 +23,12 @@ describe('ProjectService', () => { { project_id: 123, name: 'Project 1', - project_programs: [], - regions: [], - start_date: '1900-01-01', - end_date: '2200-10-10' + regions: [] }, { project_id: 456, name: 'Project 2', - project_programs: [], - regions: [], - start_date: '1900-01-01', - end_date: '2000-12-31' + regions: [] } ]; @@ -45,11 +39,9 @@ describe('ProjectService', () => { expect(repoStub).to.be.calledOnce; expect(response[0].project_id).to.equal(123); expect(response[0].name).to.equal('Project 1'); - expect(response[0].completion_status).to.equal('Active'); expect(response[1].project_id).to.equal(456); expect(response[1].name).to.equal('Project 2'); - expect(response[1].completion_status).to.equal('Completed'); }); }); diff --git a/api/src/services/project-service.ts b/api/src/services/project-service.ts index 7ce90c32a3..ab0109c023 100644 --- a/api/src/services/project-service.ts +++ b/api/src/services/project-service.ts @@ -1,4 +1,3 @@ -import { default as dayjs } from 'dayjs'; import { IDBConnection } from '../database/db'; import { HTTP400 } from '../errors/http-error'; import { IPostIUCN, PostProjectObject } from '../models/project-create'; @@ -26,17 +25,6 @@ import { PlatformService } from './platform-service'; import { ProjectParticipationService } from './project-participation-service'; import { SurveyService } from './survey-service'; -/** - * Project Completion Status - * - * @export - * @enum {string} - */ -export enum COMPLETION_STATUS { - COMPLETED = 'Completed', - ACTIVE = 'Active' -} - export class ProjectService extends DBService { attachmentService: AttachmentService; projectRepository: ProjectRepository; @@ -62,7 +50,7 @@ export class ProjectService extends DBService { * @param {(number | null)} systemUserId * @param {IProjectAdvancedFilters} filterFields * @param {ApiPaginationOptions} [pagination] - * @return {*} {(Promise<(ProjectListData & { completion_status: COMPLETION_STATUS })[]>)} + * @return {*} {(Promise<(ProjectListData)[]>)} * @memberof ProjectService */ async getProjectList( @@ -70,15 +58,10 @@ export class ProjectService extends DBService { systemUserId: number | null, filterFields: IProjectAdvancedFilters, pagination?: ApiPaginationOptions - ): Promise<(ProjectListData & { completion_status: COMPLETION_STATUS })[]> { + ): Promise { const response = await this.projectRepository.getProjectList(isUserAdmin, systemUserId, filterFields, pagination); - return response.map((row) => ({ - ...row, - completion_status: - (row.end_date && dayjs(row.end_date).endOf('day').isBefore(dayjs()) && COMPLETION_STATUS.COMPLETED) || - COMPLETION_STATUS.ACTIVE - })); + return response; } /** @@ -212,9 +195,6 @@ export class ProjectService extends DBService { ) ); - // Handle project programs - promises.push(this.insertPrograms(projectId, postProjectData.project.project_programs)); - //Handle project participants promises.push(this.projectParticipationService.postProjectParticipants(projectId, postProjectData.participants)); @@ -259,19 +239,6 @@ export class ProjectService extends DBService { return this.projectParticipationService.postProjectParticipant(projectId, systemUserId, projectParticipantRole); } - /** - * Insert programs data. - * - * @param {number} projectId - * @param {number[]} projectPrograms - * @return {*} {Promise} - * @memberof ProjectService - */ - async insertPrograms(projectId: number, projectPrograms: number[]): Promise { - await this.projectRepository.deletePrograms(projectId); - await this.projectRepository.insertProgram(projectId, projectPrograms); - } - /** * Updates the project * @@ -291,10 +258,6 @@ export class ProjectService extends DBService { promises.push(this.updateIUCNData(projectId, entities)); } - if (entities?.project?.project_programs) { - promises.push(this.insertPrograms(projectId, entities?.project?.project_programs)); - } - if (entities?.participants) { promises.push(this.projectParticipationService.upsertProjectParticipantData(projectId, entities.participants)); } diff --git a/app/src/components/search-filter/ProjectAdvancedFilters.tsx b/app/src/components/search-filter/ProjectAdvancedFilters.tsx index 114fe21f10..604678f6c7 100644 --- a/app/src/components/search-filter/ProjectAdvancedFilters.tsx +++ b/app/src/components/search-filter/ProjectAdvancedFilters.tsx @@ -1,4 +1,3 @@ -import FormControl from '@mui/material/FormControl'; import Grid from '@mui/material/Grid'; import CustomTextField from 'components/fields/CustomTextField'; import MultiAutocompleteFieldVariableSize, { @@ -13,9 +12,6 @@ import { debounce } from 'lodash-es'; import { useMemo } from 'react'; export interface IProjectAdvancedFilters { - project_programs: number[]; - start_date: string; - end_date: string; keyword: string; project_name: string; agency_id: number; @@ -24,9 +20,6 @@ export interface IProjectAdvancedFilters { } export const ProjectAdvancedFiltersInitialValues: IProjectAdvancedFilters = { - project_programs: [], - start_date: '', - end_date: '', keyword: '', project_name: '', agency_id: '' as unknown as number, @@ -106,19 +99,6 @@ const ProjectAdvancedFilters = () => { search={handleSearch} />
- - - { - return { value: item.id, label: item.name }; - }) ?? [] - } - /> - - { it('renders correctly with default empty values', async () => { const { getByLabelText } = render( @@ -31,7 +15,7 @@ describe('ProjectDetailsForm', () => { validateOnBlur={true} validateOnChange={false} onSubmit={async () => {}}> - {() => } + {() => } ); @@ -43,27 +27,23 @@ describe('ProjectDetailsForm', () => { it('renders correctly with existing details values', async () => { const existingFormValues: IProjectDetailsForm = { project: { - project_name: 'name 1', - project_programs: [2], - start_date: '2021-03-14', - end_date: '2021-04-14' + project_name: 'name 1' } }; - const { getByLabelText, getByText } = render( + const { getByLabelText } = render( {}}> - {() => } + {() => } ); await waitFor(() => { expect(getByLabelText('Project Name', { exact: false })).toBeVisible(); - expect(getByText('type 2', { exact: false })).toBeVisible(); }); }); }); diff --git a/app/src/features/projects/components/ProjectDetailsForm.tsx b/app/src/features/projects/components/ProjectDetailsForm.tsx index fb2878008f..1bc437e569 100644 --- a/app/src/features/projects/components/ProjectDetailsForm.tsx +++ b/app/src/features/projects/components/ProjectDetailsForm.tsx @@ -1,58 +1,36 @@ -import FormControl from '@mui/material/FormControl'; import Grid from '@mui/material/Grid'; import CustomTextField from 'components/fields/CustomTextField'; -import MultiAutocompleteFieldVariableSize, { - IMultiAutocompleteFieldOption -} from 'components/fields/MultiAutocompleteFieldVariableSize'; -import StartEndDateFields from 'components/fields/StartEndDateFields'; import { useFormikContext } from 'formik'; import { ICreateProjectRequest } from 'interfaces/useProjectApi.interface'; -import React from 'react'; import yup from 'utils/YupSchema'; export interface IProjectDetailsForm { project: { project_name: string; - project_programs: number[]; - start_date: string; - end_date: string; }; } export const ProjectDetailsFormInitialValues: IProjectDetailsForm = { project: { - project_name: '', - project_programs: [], - start_date: '', - end_date: '' + project_name: '' } }; export const ProjectDetailsFormYupSchema = yup.object().shape({ project: yup.object().shape({ - project_name: yup.string().max(300, 'Cannot exceed 300 characters').required('Project Name is Required'), - project_programs: yup - .array(yup.number()) - .min(1, 'At least 1 Project Program is Required') - .required('Project Program is Required'), - start_date: yup.string().isValidDateString().required('Start Date is Required'), - end_date: yup.string().nullable().isValidDateString().isEndDateSameOrAfterStartDate('start_date') + project_name: yup.string().max(300, 'Cannot exceed 300 characters').required('Project Name is Required') }) }); -export interface IProjectDetailsFormProps { - program: IMultiAutocompleteFieldOption[]; -} - /** * Create project - General information section * * @return {*} */ -const ProjectDetailsForm: React.FC = (props) => { +const ProjectDetailsForm = () => { const formikProps = useFormikContext(); - const { touched, errors, handleSubmit } = formikProps; + const { handleSubmit } = formikProps; return (
@@ -66,29 +44,6 @@ const ProjectDetailsForm: React.FC = (props) => { }} /> - - - - - - - - ); diff --git a/app/src/features/projects/edit/EditProjectForm.tsx b/app/src/features/projects/edit/EditProjectForm.tsx index 32d0c168d4..021ecd5020 100644 --- a/app/src/features/projects/edit/EditProjectForm.tsx +++ b/app/src/features/projects/edit/EditProjectForm.tsx @@ -53,13 +53,7 @@ const EditProjectForm = - { - return { value: item.id, label: item.name }; - }) || [] - } - /> + diff --git a/app/src/features/projects/list/ProjectsListPage.test.tsx b/app/src/features/projects/list/ProjectsListPage.test.tsx index d041bf690a..12154fed19 100644 --- a/app/src/features/projects/list/ProjectsListPage.test.tsx +++ b/app/src/features/projects/list/ProjectsListPage.test.tsx @@ -115,9 +115,7 @@ describe('ProjectsListPage', () => { name: 'Project 1', start_date: null, end_date: null, - project_programs: [1], - regions: ['region'], - completion_status: 'Completed' + regions: ['region'] } ], pagination: { diff --git a/app/src/features/projects/list/ProjectsListPage.tsx b/app/src/features/projects/list/ProjectsListPage.tsx index a35054bac4..88589e08d3 100644 --- a/app/src/features/projects/list/ProjectsListPage.tsx +++ b/app/src/features/projects/list/ProjectsListPage.tsx @@ -32,10 +32,6 @@ import { ApiPaginationRequestOptions } from 'types/misc'; import { firstOrNull, getFormattedDate } from 'utils/Utils'; import ProjectsListFilterForm from './ProjectsListFilterForm'; -interface IProjectsListTableRow extends Omit { - project_programs: string; -} - const pageSizeOptions = [10, 25, 50]; /** @@ -76,24 +72,9 @@ const ProjectsListPage = () => { }; }); - const getProjectPrograms = (project: IProjectsListItemData) => { - return ( - codesContext.codesDataLoader.data?.program - .filter((code) => project.project_programs.includes(code.id)) - .map((code) => code.name) - .join(', ') || '' - ); - }; - - const projectRows = - projectsDataLoader.data?.projects.map((project) => { - return { - ...project, - project_programs: getProjectPrograms(project) - }; - }) ?? []; - - const columns: GridColDef[] = [ + const projectRows = projectsDataLoader.data?.projects ?? []; + + const columns: GridColDef[] = [ { field: 'name', headerName: 'Name', @@ -111,11 +92,6 @@ const ProjectsListPage = () => { /> ) }, - { - field: 'project_programs', - headerName: 'Programs', - flex: 1 - }, { field: 'regions', headerName: 'Regions', diff --git a/app/src/features/projects/view/ProjectDetails.tsx b/app/src/features/projects/view/ProjectDetails.tsx index 8e706111b6..b07dfa9faf 100644 --- a/app/src/features/projects/view/ProjectDetails.tsx +++ b/app/src/features/projects/view/ProjectDetails.tsx @@ -6,7 +6,6 @@ import Typography from '@mui/material/Typography'; import assert from 'assert'; import { ProjectContext } from 'contexts/projectContext'; import { useContext } from 'react'; -import GeneralInformation from './components/GeneralInformation'; import ProjectObjectives from './components/ProjectObjectives'; import TeamMembers from './components/TeamMember'; @@ -77,14 +76,6 @@ const ProjectDetails = () => {
- - - General Information - - - - - Team Members diff --git a/app/src/features/projects/view/ProjectHeader.tsx b/app/src/features/projects/view/ProjectHeader.tsx index 1ff904fc41..60d2144ef7 100644 --- a/app/src/features/projects/view/ProjectHeader.tsx +++ b/app/src/features/projects/view/ProjectHeader.tsx @@ -1,7 +1,5 @@ import { mdiAccountMultipleOutline, - mdiCalendarRange, - mdiCalendarTodayOutline, mdiChevronDown, mdiCogOutline, mdiPencilOutline, @@ -9,17 +7,14 @@ import { } from '@mdi/js'; import Icon from '@mdi/react'; import Button from '@mui/material/Button'; -import grey from '@mui/material/colors/grey'; import ListItemIcon from '@mui/material/ListItemIcon'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; -import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import assert from 'assert'; import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; import PageHeader from 'components/layout/PageHeader'; import { ProjectRoleGuard } from 'components/security/Guards'; -import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { DeleteProjectI18N } from 'constants/i18n'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; import { DialogContext } from 'contexts/dialogContext'; @@ -28,7 +23,6 @@ import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; import React, { useContext } from 'react'; import { useHistory } from 'react-router'; -import { getFormattedDateRangeString } from 'utils/Utils'; /** * Project header for a single-project view. @@ -104,30 +98,6 @@ const ProjectHeader = () => { <> - {projectData.projectData.project.end_date ? ( - - - - {getFormattedDateRangeString( - DATE_FORMAT.MediumDateFormat, - projectData.projectData.project.start_date, - projectData.projectData.project.end_date - )} - - ) : ( - - - Start Date: - {getFormattedDateRangeString( - DATE_FORMAT.ShortMediumDateFormat, - projectData.projectData.project.start_date - )} - - )} - - } buttonJSX={ { - const codesContext = useContext(CodesContext); - const projectContext = useContext(ProjectContext); - - // Codes data must be loaded by a parent before this component is rendered - assert(codesContext.codesDataLoader.data); - // Project data must be loaded by a parent before this component is rendered - assert(projectContext.projectDataLoader.data); - - const codes = codesContext.codesDataLoader.data; - const projectData = projectContext.projectDataLoader.data.projectData; - - const projectPrograms = - codes.program - .filter((code) => projectData.project.project_programs.includes(code.id)) - .map((code) => code.name) - .join(', ') || ''; - - return ( - - - - Program - - {projectPrograms ? <>{projectPrograms} : 'No Programs'} - - - - Timeline - - - {projectData.project.end_date ? ( - <> - {getFormattedDateRangeString( - DATE_FORMAT.ShortMediumDateFormat, - projectData.project.start_date, - projectData.project.end_date - )} - - ) : ( - <> - Start Date: - {getFormattedDateRangeString(DATE_FORMAT.ShortMediumDateFormat, projectData.project.start_date)} - - )} - - - - ); -}; - -export default GeneralInformation; diff --git a/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx b/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx index 32f82f85d0..0dedfb5b46 100644 --- a/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx +++ b/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx @@ -79,7 +79,7 @@ export const GeneralInformationYupSchema = () => { survey_details: yup.object().shape({ survey_name: yup.string().required('Survey Name is Required'), start_date: yup.string().isValidDateString().required('Start Date is Required'), - end_date: yup.string().nullable().isValidDateString().isEndDateSameOrAfterStartDate('start_date'), + end_date: yup.string().nullable().isValidDateString(), survey_types: yup .array(yup.number()) .min(1, 'One or more Types are required') @@ -100,8 +100,6 @@ export const GeneralInformationYupSchema = () => { export interface IGeneralInformationFormProps { type: IMultiAutocompleteFieldOption[]; - projectStartDate: string; - projectEndDate: string; progress: ISelectWithSubtextFieldOption[]; } diff --git a/app/src/features/surveys/edit/EditSurveyForm.tsx b/app/src/features/surveys/edit/EditSurveyForm.tsx index ae7287d58e..0335e9eec6 100644 --- a/app/src/features/surveys/edit/EditSurveyForm.tsx +++ b/app/src/features/surveys/edit/EditSurveyForm.tsx @@ -84,8 +84,6 @@ const EditSurveyForm = (props: IEditSurveyForm) => { return { value: item.id, label: item.name, subText: item.description }; }) || [] } - projectStartDate={projectData.project.start_date} - projectEndDate={projectData.project.end_date} /> }> diff --git a/app/src/interfaces/useCodesApi.interface.ts b/app/src/interfaces/useCodesApi.interface.ts index ec4845fec5..9df48a4aa8 100644 --- a/app/src/interfaces/useCodesApi.interface.ts +++ b/app/src/interfaces/useCodesApi.interface.ts @@ -27,7 +27,6 @@ export interface IGetAllCodeSetsResponse { investment_action_category: CodeSet<{ id: number; agency_id: number; name: string }>; type: CodeSet; proprietor_type: CodeSet<{ id: number; name: string; is_first_nation: boolean }>; - program: CodeSet; iucn_conservation_action_level_1_classification: CodeSet; iucn_conservation_action_level_2_subclassification: CodeSet<{ id: number; iucn1_id: number; name: string }>; iucn_conservation_action_level_3_subclassification: CodeSet<{ id: number; iucn2_id: number; name: string }>; diff --git a/app/src/interfaces/useProjectApi.interface.ts b/app/src/interfaces/useProjectApi.interface.ts index 82fa76e45e..c15075b738 100644 --- a/app/src/interfaces/useProjectApi.interface.ts +++ b/app/src/interfaces/useProjectApi.interface.ts @@ -91,11 +91,7 @@ export interface IGetProjectsListResponse { export interface IProjectsListItemData { project_id: number; name: string; - start_date: string; - end_date?: string; - completion_status: string; regions: string[]; - project_programs: number[]; } export interface IProjectUserRoles { @@ -143,9 +139,6 @@ export interface IGetProjectForUpdateResponse { export interface IGetProjectForUpdateResponseDetails { project_name: string; - project_programs: number[]; - start_date: string; - end_date: string; revision_count: number; } export interface IGetProjectForUpdateResponseObjectives { @@ -199,10 +192,6 @@ export interface ProjectViewObject { export interface IGetProjectForViewResponseDetails { project_id: number; project_name: string; - project_programs: number[]; - start_date: string; - end_date: string; - completion_status: string; } export interface IGetProjectForViewResponseObjectives { objectives: string; diff --git a/app/src/test-helpers/code-helpers.ts b/app/src/test-helpers/code-helpers.ts index 61b4e85863..63212d6756 100644 --- a/app/src/test-helpers/code-helpers.ts +++ b/app/src/test-helpers/code-helpers.ts @@ -6,7 +6,6 @@ export const codes: IGetAllCodeSetsResponse = { agency: [{ id: 1, name: 'Funding source code' }], investment_action_category: [{ id: 1, agency_id: 1, name: 'Investment action category' }], type: [{ id: 1, name: 'Type code' }], - program: [{ id: 1, name: 'Program' }], proprietor_type: [ { id: 1, name: 'Proprietor code 1', is_first_nation: false }, { id: 2, name: 'First Nations Land', is_first_nation: true } diff --git a/app/src/test-helpers/project-helpers.ts b/app/src/test-helpers/project-helpers.ts index 00ae48211d..c5a5547948 100644 --- a/app/src/test-helpers/project-helpers.ts +++ b/app/src/test-helpers/project-helpers.ts @@ -4,11 +4,7 @@ export const getProjectForViewResponse: IGetProjectForViewResponse = { projectData: { project: { project_id: 1, - project_name: 'Test Project Name', - project_programs: [], - start_date: '1998-10-10', - end_date: '2021-02-26', - completion_status: 'Active' + project_name: 'Test Project Name' }, objectives: { objectives: 'Et ad et in culpa si' diff --git a/database/src/migrations/20240620000000_project_changes.ts b/database/src/migrations/20240620000000_project_changes.ts new file mode 100644 index 0000000000..27b4524413 --- /dev/null +++ b/database/src/migrations/20240620000000_project_changes.ts @@ -0,0 +1,53 @@ +import { Knex } from 'knex'; + +/** + * Drop deprecated columns, tables, and triggers. + * + * Remove `project` columns + * - start_date + * - end_date + * Remove tables + * - project_program + * - program + * Remove triggers + * - project_val + * - permit_val + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + + SET SEARCH_PATH='biohub,biohub_dapi_v1'; + + -- Drop the views + DROP VIEW IF EXISTS biohub_dapi_v1.project; + DROP VIEW IF EXISTS biohub_dapi_v1.project_program; + DROP VIEW IF EXISTS biohub_dapi_v1.program; + + -- Drop the project_program table and program codes table + DROP TABLE IF EXISTS biohub.project_program; + DROP TABLE IF EXISTS biohub.program; + + -- Drop the triggers + DROP TRIGGER IF EXISTS project_val ON biohub.project; + DROP TRIGGER IF EXISTS permit_val ON biohub.permit; + + -- Drop the functions associated with the triggers + DROP FUNCTION IF EXISTS biohub.tr_project(); + DROP FUNCTION IF EXISTS biohub.tr_permit(); + + -- Drop columns start_date and end_date from the project table + ALTER TABLE biohub.project DROP COLUMN start_date; + ALTER TABLE biohub.project DROP COLUMN end_date; + + -- Recreate the view + CREATE OR REPLACE VIEW biohub_dapi_v1.project AS SELECT * FROM biohub.project; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/procedures/delete_project_procedure.ts b/database/src/procedures/delete_project_procedure.ts index 0b4e4b2c23..ff1ff2737e 100644 --- a/database/src/procedures/delete_project_procedure.ts +++ b/database/src/procedures/delete_project_procedure.ts @@ -36,8 +36,6 @@ export async function seed(knex: Knex): Promise { delete from project_report_attachment where project_id = p_project_id; delete from project_participation where project_id = p_project_id; delete from project_metadata_publish where project_id = p_project_id; - delete from project_region where project_id = p_project_id; - delete from project_program where project_id = p_project_id; delete from grouping_project where project_id = p_project_id; delete from project where project_id = p_project_id; diff --git a/database/src/procedures/tr_project.ts b/database/src/procedures/tr_project.ts deleted file mode 100644 index ddb76a2a2d..0000000000 --- a/database/src/procedures/tr_project.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Knex } from 'knex'; - -/** - * Inserts a trigger function that validates the start and end date of a project. - * - * - The project start date cannot be greater than the project end date. - * - * @export - * @param {Knex} knex - * @return {*} {Promise} - */ -export async function seed(knex: Knex): Promise { - await knex.raw(`--sql - CREATE OR REPLACE FUNCTION biohub.tr_project() - RETURNS trigger - LANGUAGE plpgsql - SECURITY invoker - AS $function$ - BEGIN - -- Assert project start date is not greater than the end date, if the end date is not null. - IF (new.end_date IS NOT NULL) THEN - IF (new.end_date < new.start_date) THEN - RAISE EXCEPTION 'The project start date cannot be greater than the end date.'; - END IF; - END IF; - - RETURN new; - END; - $function$; - - DROP TRIGGER IF EXISTS project_val ON biohub.project; - CREATE TRIGGER project_val BEFORE INSERT OR UPDATE ON biohub.project FOR EACH ROW EXECUTE PROCEDURE tr_project(); - `); -} diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index d5d5d8b1a8..ea98085d57 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -60,11 +60,10 @@ export async function seed(knex: Knex): Promise { const createProjectResponse = await knex.raw(insertProjectData(`Seed Project ${i + 1}`)); const projectId = createProjectResponse.rows[0].project_id; - // Insert project IUCN, participant and program data + // Insert project IUCN and participants await knex.raw(` ${insertProjectIUCNData(projectId)} ${insertProjectParticipationData(projectId)} - ${insertProjectProgramData(projectId)} `); // Insert survey data @@ -158,22 +157,6 @@ const insertSurveySiteStrategy = (surveyId: number) => ` ); `; -/** - * SQL to insert Project Program data - * - */ -const insertProjectProgramData = (projectId: number) => ` - INSERT into project_program - ( - project_id, - program_id - ) - VALUES ( - ${projectId}, - (select program_id from program order by random() limit 1) - ); -`; - const insertSurveyParticipationData = (surveyId: number) => ` INSERT into survey_participation ( survey_id, system_user_id, survey_job_id ) @@ -711,8 +694,6 @@ const insertProjectData = (projectName?: string) => ` name, objectives, location_description, - start_date, - end_date, geography, geojson ) @@ -720,8 +701,6 @@ const insertProjectData = (projectName?: string) => ` '${projectName ?? 'Seed Project'}', $$${faker.lorem.sentences(2)}$$, $$${faker.lorem.sentences(2)}$$, - $$${faker.date.between({ from: '2000-01-01T00:00:00-08:00', to: '2005-01-01T00:00:00-08:00' }).toISOString()}$$, - $$${faker.date.between({ from: '2025-01-01T00:00:00-08:00', to: '2030-01-01T00:00:00-08:00' }).toISOString()}$$, 'POLYGON ((-121.904297 50.930738, -121.904297 51.971346, -120.19043 51.971346, -120.19043 50.930738, -121.904297 50.930738))', '[ { diff --git a/scripts/bctw-deployments/main.js b/scripts/bctw-deployments/main.js index 9ca84111b0..37ccad0508 100755 --- a/scripts/bctw-deployments/main.js +++ b/scripts/bctw-deployments/main.js @@ -16,7 +16,6 @@ const CONFIG = { last_name: "Aubertin-Young", email: "Macgregor.Aubertin-Young@gov.bc.ca", project_role: "Coordinator", - project_program: "Wildlife", survey_status: "Completed", survey_type: "Monitoring", survey_intended_outcome_1: "Mortality", @@ -87,7 +86,7 @@ const jqPreParseInputFile = async (fileName) => { }) }) }' < ${fileName} - `, + ` ); return JSON.parse(data); @@ -161,9 +160,9 @@ async function main() { for (let pIndex = 0; pIndex < data.length; pIndex++) { const project = data[pIndex]; - sql += `WITH p AS (INSERT INTO project (name, objectives, coordinator_first_name, coordinator_last_name, coordinator_email_address, start_date, end_date) VALUES ($$Caribou - ${project.herd} - BCTW Telemetry$$, $$BCTW telemetry deployments for ${project.herd} Caribou$$, $$${CONFIG.first_name}$$, $$${CONFIG.last_name}$$, $$${CONFIG.email}$$, $$${project.start_date}$$, $$${project.end_date}$$) RETURNING project_id + sql += `WITH p AS (INSERT INTO project (name, objectives, coordinator_first_name, coordinator_last_name, coordinator_email_address) VALUES ($$Caribou - ${project.herd} - BCTW Telemetry$$, $$BCTW telemetry deployments for ${project.herd} Caribou$$, $$${CONFIG.first_name}$$, $$${CONFIG.last_name}$$, $$${CONFIG.email}$$) RETURNING project_id ), ppp AS (INSERT INTO project_participation (project_id, system_user_id, project_role_id) SELECT project_id, (select system_user_id from system_user where user_identifier = $$mauberti$$), (select project_role_id from project_role where name = $$${CONFIG.project_role}$$) FROM p - ), pp AS (INSERT INTO project_program (project_id, program_id) SELECT project_id, (select program_id from program where name = $$${CONFIG.project_program}$$) FROM p + ) `; for (let sIndex = 0; sIndex < project.surveys.length; sIndex++) { const survey = project.surveys[sIndex]; @@ -206,7 +205,7 @@ async function main() { } process.stdout.write( - `SET search_path=public,biohub; BEGIN; ${sql} COMMIT;`, + `SET search_path=public,biohub; BEGIN; ${sql} COMMIT;` ); } catch (err) { process.stderr.write(`main.js -> ${err}`); From e4a401e3528d9d730db7614e2c95052becba3bef Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Fri, 21 Jun 2024 16:56:23 -0700 Subject: [PATCH 3/3] SIMSBIOHUB-593: Add constraint for a user to have only one role per Project (#1307) * Add database constraint to enforce one role per user per project * Add project member role icons to form control --------- Co-authored-by: Nick Phura --- api/src/repositories/code-repository.ts | 4 +- .../project-participation-service.test.ts | 123 +++++++++++++++++- .../services/project-participation-service.ts | 122 +++++++++++++---- app/src/components/user/UserRoleSelector.tsx | 22 +++- app/src/constants/roles.ts | 13 ++ .../projects/components/ProjectUserForm.tsx | 7 +- .../features/projects/view/ProjectDetails.tsx | 8 +- .../projects/view/components/TeamMember.tsx | 43 +++--- app/src/hooks/useDataLoader.ts | 1 - ...000000_project_participation_constraint.ts | 33 +++++ 10 files changed, 322 insertions(+), 54 deletions(-) create mode 100644 database/src/migrations/20240618000000_project_participation_constraint.ts diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index cd5550dd74..8b3894ede8 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -327,7 +327,9 @@ export class CodeRepository extends BaseRepository { project_role_id as id, name FROM project_role - WHERE record_end_date is null; + WHERE record_end_date is null + ORDER BY + CASE WHEN name = 'Coordinator' THEN 0 ELSE 1 END; `; const response = await this.connection.sql(sqlStatement, ICode); diff --git a/api/src/services/project-participation-service.test.ts b/api/src/services/project-participation-service.test.ts index 7601c46ac4..f76b9625c7 100644 --- a/api/src/services/project-participation-service.test.ts +++ b/api/src/services/project-participation-service.test.ts @@ -944,7 +944,7 @@ describe('ProjectParticipationService', () => { }); }); - describe('doProjectParticipantsHaveARole', () => { + describe('_doProjectParticipantsHaveARole', () => { it('should return true if one project user has a specified role', () => { const projectUsers: PostParticipantData[] = [ { @@ -962,7 +962,7 @@ describe('ProjectParticipationService', () => { const dbConnection = getMockDBConnection(); const service = new ProjectParticipationService(dbConnection); - const result = service.doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); + const result = service._doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); expect(result).to.be.true; }); @@ -984,7 +984,7 @@ describe('ProjectParticipationService', () => { const dbConnection = getMockDBConnection(); const service = new ProjectParticipationService(dbConnection); - const result = service.doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); + const result = service._doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); expect(result).to.be.true; }); @@ -1006,7 +1006,97 @@ describe('ProjectParticipationService', () => { const dbConnection = getMockDBConnection(); const service = new ProjectParticipationService(dbConnection); - const result = service.doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); + const result = service._doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); + + expect(result).to.be.false; + }); + }); + + describe('_doProjectParticipantsHaveOneRole', () => { + it('should return true if one project user has one specified role', () => { + const projectUsers: PostParticipantData[] = [ + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.COLLABORATOR] + } + ]; + + const dbConnection = getMockDBConnection(); + const service = new ProjectParticipationService(dbConnection); + + const result = service._doProjectParticipantsHaveOneRole(projectUsers); + + expect(result).to.be.true; + }); + + it('should return true if multiple project users have one specified role', () => { + const projectUsers: PostParticipantData[] = [ + { + project_participation_id: 12, + system_user_id: 11, + project_role_names: [PROJECT_ROLE.COLLABORATOR] + }, + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.OBSERVER] + } + ]; + + const dbConnection = getMockDBConnection(); + const service = new ProjectParticipationService(dbConnection); + + const result = service._doProjectParticipantsHaveOneRole(projectUsers); + + expect(result).to.be.true; + }); + + it('should return false if a participant has multiple specified role', () => { + const projectUsers: PostParticipantData[] = [ + { + project_participation_id: 12, + system_user_id: 11, + project_role_names: [PROJECT_ROLE.COORDINATOR] + }, + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.OBSERVER] + }, + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.COLLABORATOR] + } + ]; + + const dbConnection = getMockDBConnection(); + const service = new ProjectParticipationService(dbConnection); + + const result = service._doProjectParticipantsHaveOneRole(projectUsers); + + expect(result).to.be.false; + }); + + it('should return false if a participant has multiple specified roles in the same record', () => { + const projectUsers: PostParticipantData[] = [ + { + project_participation_id: 12, + system_user_id: 11, + project_role_names: [PROJECT_ROLE.COORDINATOR] + }, + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.OBSERVER, PROJECT_ROLE.COLLABORATOR] + } + ]; + + const dbConnection = getMockDBConnection(); + const service = new ProjectParticipationService(dbConnection); + + const result = service._doProjectParticipantsHaveOneRole(projectUsers); expect(result).to.be.false; }); @@ -1058,6 +1148,10 @@ describe('ProjectParticipationService', () => { project_participation_id: 12, project_role_names: [PROJECT_ROLE.COORDINATOR] // Existing user to be updated }, + { + system_user_id: 33, + project_role_names: [PROJECT_ROLE.COLLABORATOR] // Existing user to be unaffected + }, { system_user_id: 44, project_role_names: [PROJECT_ROLE.OBSERVER] // New user @@ -1086,6 +1180,25 @@ describe('ProjectParticipationService', () => { user_guid: '123-456-789-1', user_identifier: 'testuser1' }, + { + project_participation_id: 6, // Existing user to be unaffected + project_id: 1, + system_user_id: 33, + project_role_ids: [2], + project_role_names: [PROJECT_ROLE.COLLABORATOR], + project_role_permissions: ['Permission1'], + agency: null, + display_name: 'test user 1', + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + record_end_date: null, + role_ids: [2], + role_names: [SYSTEM_ROLE.PROJECT_CREATOR], + user_guid: '123-456-789-1', + user_identifier: 'testuser1' + }, { project_participation_id: 23, // Existing user to be removed project_id: 1, @@ -1121,6 +1234,8 @@ describe('ProjectParticipationService', () => { expect(getProjectParticipantsStub).to.have.been.calledOnceWith(projectId); expect(deleteProjectParticipationRecordStub).to.have.been.calledWith(1, 23); expect(updateProjectParticipationRoleStub).to.have.been.calledOnceWith(12, PROJECT_ROLE.COORDINATOR); + expect(updateProjectParticipationRoleStub).to.not.have.been.calledWith(6, PROJECT_ROLE.COLLABORATOR); + expect(postProjectParticipantStub).to.not.have.been.calledWith(projectId, 6, PROJECT_ROLE.COLLABORATOR); expect(postProjectParticipantStub).to.have.been.calledOnceWith(projectId, 44, PROJECT_ROLE.OBSERVER); }); }); diff --git a/api/src/services/project-participation-service.ts b/api/src/services/project-participation-service.ts index 4b008caa4b..faa4e29cac 100644 --- a/api/src/services/project-participation-service.ts +++ b/api/src/services/project-participation-service.ts @@ -314,50 +314,128 @@ export class ProjectParticipationService extends DBService { return true; } - doProjectParticipantsHaveARole(participants: PostParticipantData[], roleToCheck: PROJECT_ROLE): boolean { + /** + * Internal function for validating that all Project members have a role + * + * @param {PostParticipantData[]} participants + * @param {PROJECT_ROLE} roleToCheck + * @return {*} {boolean} + * @memberof ProjectParticipationService + */ + _doProjectParticipantsHaveARole(participants: PostParticipantData[], roleToCheck: PROJECT_ROLE): boolean { return participants.some((item) => item.project_role_names.some((role) => role === roleToCheck)); } - async upsertProjectParticipantData(projectId: number, participants: PostParticipantData[]): Promise { - if (!this.doProjectParticipantsHaveARole(participants, PROJECT_ROLE.COORDINATOR)) { + /** + * Internal function for validating that all project participants have one unique role. + * + * @param {PostParticipantData[]} participants + * @return {*} {boolean} + * @memberof ProjectParticipationService + */ + _doProjectParticipantsHaveOneRole(participants: PostParticipantData[]): boolean { + // Map of system_user_id to set of unique role names + const participantUniqueRoles = new Map>(); + + for (const participant of participants) { + const system_user_id = participant.system_user_id; + const project_role_names = participant.project_role_names; + + // Get the set of unique role names, or initialize a new set if the user is not in the map + const uniqueRoleNamesForParticipant = participantUniqueRoles.get(system_user_id) ?? new Set(); + + for (const role of project_role_names) { + // Add the role names to the set, converting to lowercase to ensure case-insensitive comparison + uniqueRoleNamesForParticipant.add(role.toLowerCase()); + } + + // Update the map with the new set of unique role names + participantUniqueRoles.set(system_user_id, uniqueRoleNamesForParticipant); + } + + // Returns true if all participants have one unique role + return Array.from(participantUniqueRoles.values()).every((roleNames) => roleNames.size === 1); + } + + /** + * Updates existing participants, deletes those participants not in the incoming list, and inserts new participants. + * + * @param {number} projectId + * @param {PostParticipantData[]} incomingParticipants + * @return {*} {Promise} + * @throws ApiGeneralError If no participant has a coordinator role or if any participant has multiple roles. + * @memberof ProjectParticipationService + */ + async upsertProjectParticipantData(projectId: number, incomingParticipants: PostParticipantData[]): Promise { + // Confirm that at least one participant has a coordinator role + if (!this._doProjectParticipantsHaveARole(incomingParticipants, PROJECT_ROLE.COORDINATOR)) { throw new ApiGeneralError( `Projects require that at least one participant has a ${PROJECT_ROLE.COORDINATOR} role.` ); } - // all actions to take - const promises: Promise[] = []; + // Check for multiple roles for any participant + if (!this._doProjectParticipantsHaveOneRole(incomingParticipants)) { + throw new ApiGeneralError( + 'Users can only have one role per Project but multiple roles were specified for at least one user.' + ); + } - // get the existing participants for a project + // Fetch existing participants for the project const existingParticipants = await this.projectParticipationRepository.getProjectParticipants(projectId); - // Compare incoming with existing to find any outliers to delete + // Prepare promises for all database operations + const promises: Promise[] = []; + + // Identify participants to delete const participantsToDelete = existingParticipants.filter( - (item) => !participants.find((incoming) => incoming.system_user_id === item.system_user_id) + (existingParticipant) => + !incomingParticipants.some( + (incomingParticipant) => incomingParticipant.system_user_id === existingParticipant.system_user_id + ) ); - // delete - participantsToDelete.forEach((item) => { + // Delete participants not present in the incoming payload + participantsToDelete.forEach((participantToDelete) => { promises.push( - this.projectParticipationRepository.deleteProjectParticipationRecord(projectId, item.project_participation_id) + this.projectParticipationRepository.deleteProjectParticipationRecord( + projectId, + participantToDelete.project_participation_id + ) ); }); - participants.forEach((item) => { - if (item.project_participation_id) { + // Upsert participants based on conditions + incomingParticipants.forEach((incomingParticipant) => { + const existingParticipant = existingParticipants.find( + (existingParticipant) => existingParticipant.system_user_id === incomingParticipant.system_user_id + ); + + if (existingParticipant) { + // Update existing participant's role + if ( + !existingParticipant.project_role_names.some((existingRole) => + incomingParticipant.project_role_names.includes(existingRole as PROJECT_ROLE) + ) + ) { + promises.push( + this.projectParticipationRepository.updateProjectParticipationRole( + incomingParticipant.project_participation_id ?? existingParticipant.project_participation_id, + incomingParticipant.project_role_names[0] + ) + ); + } + } else if (!existingParticipant) { + // Insert new participant if the user does not already exist in the project, otherwise triggers database constraint error promises.push( - this.projectParticipationRepository.updateProjectParticipationRole( - item.project_participation_id, - item.project_role_names[0] + this.projectParticipationRepository.postProjectParticipant( + projectId, + incomingParticipant.system_user_id, + incomingParticipant.project_role_names[0] ) ); - } else { - this.projectParticipationRepository.postProjectParticipant( - projectId, - item.system_user_id, - item.project_role_names[0] - ); } + // If the participant already exists with the desired role, do nothing }); await Promise.all(promises); diff --git a/app/src/components/user/UserRoleSelector.tsx b/app/src/components/user/UserRoleSelector.tsx index 122533cf93..fbfcf2adf1 100644 --- a/app/src/components/user/UserRoleSelector.tsx +++ b/app/src/components/user/UserRoleSelector.tsx @@ -1,11 +1,13 @@ import { mdiClose } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; -import { grey } from '@mui/material/colors'; +import grey from '@mui/material/colors/grey'; import IconButton from '@mui/material/IconButton'; import MenuItem from '@mui/material/MenuItem'; import Paper from '@mui/material/Paper'; import Select from '@mui/material/Select'; +import Typography from '@mui/material/Typography'; +import { PROJECT_ROLE_ICONS } from 'constants/roles'; import { ICode } from 'interfaces/useCodesApi.interface'; import { IGetProjectParticipant } from 'interfaces/useProjectApi.interface'; import { IGetSurveyParticipant } from 'interfaces/useSurveyApi.interface'; @@ -64,11 +66,27 @@ const UserRoleSelector: React.FC = (props) => { if (!selected) { return props.label; } - return selected; + return ( + + {selected} + {PROJECT_ROLE_ICONS[selected] && ( + <> +   + + + )} + + ); }}> {roles.map((item) => ( {item.name} + {PROJECT_ROLE_ICONS[item.name] && ( + <> +   + + + )} ))} diff --git a/app/src/constants/roles.ts b/app/src/constants/roles.ts index dd329207e9..ea1110d987 100644 --- a/app/src/constants/roles.ts +++ b/app/src/constants/roles.ts @@ -1,3 +1,5 @@ +import { mdiAccountEdit, mdiCrown } from '@mdi/js'; + /** * System level roles. * @@ -33,3 +35,14 @@ export enum PROJECT_PERMISSION { COLLABORATOR = 'Collaborator', OBSERVER = 'Observer' } + +/** + * Project role icons + * + * @export + */ +export const PROJECT_ROLE_ICONS: Record = { + Coordinator: mdiCrown, + Collaborator: mdiAccountEdit, + Observer: undefined +}; diff --git a/app/src/features/projects/components/ProjectUserForm.tsx b/app/src/features/projects/components/ProjectUserForm.tsx index a98c716593..6baa68ebb4 100644 --- a/app/src/features/projects/components/ProjectUserForm.tsx +++ b/app/src/features/projects/components/ProjectUserForm.tsx @@ -244,7 +244,12 @@ const ProjectUserForm = (props: IProjectUserFormProps) => { {values.participants.map((user: ISystemUser | IGetProjectParticipant, index: number) => { const error = rowItemError(index); return ( - + { Project Details - + Project Objectives - + @@ -80,7 +80,7 @@ const ProjectDetails = () => { Team Members - + @@ -89,7 +89,7 @@ const ProjectDetails = () => { IUCN Classification - + */}
diff --git a/app/src/features/projects/view/components/TeamMember.tsx b/app/src/features/projects/view/components/TeamMember.tsx index b90c081535..08b47ebba7 100644 --- a/app/src/features/projects/view/components/TeamMember.tsx +++ b/app/src/features/projects/view/components/TeamMember.tsx @@ -1,9 +1,10 @@ -import { mdiAccountEdit, mdiCrown } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import assert from 'assert'; +import { PROJECT_ROLE_ICONS } from 'constants/roles'; import { ProjectContext } from 'contexts/projectContext'; import { useContext, useMemo } from 'react'; import { getRandomHexColor } from 'utils/Utils'; @@ -55,30 +56,34 @@ const TeamMembers = () => { return ( {projectTeamMembers.map((member) => { - const isCoordinator = member.roles.includes('Coordinator'); - const isCollaborator = member.roles.includes('Collaborator'); return ( + {/* Avatar Box */} + sx={{ + height: '35px', + width: '35px', + minWidth: '35px', + borderRadius: '50%', + bgcolor: member.avatarColor, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + mr: 1 + }}> {member.initials} - + + {/* Member Display Name and Roles */} + {member.display_name} - {(isCoordinator || isCollaborator) && ( - - )} + + {/* Roles with Icons */} + {member.roles.map((role) => ( + + + + ))} ); diff --git a/app/src/hooks/useDataLoader.ts b/app/src/hooks/useDataLoader.ts index a0600e8a6c..22c3ee7f2b 100644 --- a/app/src/hooks/useDataLoader.ts +++ b/app/src/hooks/useDataLoader.ts @@ -137,7 +137,6 @@ export default function useDataLoader} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + SET SEARCH_PATH = biohub,biohub_dapi_v1; + + DROP VIEW IF EXISTS biohub_dapi_v1.project_participation; + + ---------------------------------------------------------------------------------------- + -- Add constraint to ensure a user can only have one role within a Project + ---------------------------------------------------------------------------------------- + + ALTER TABLE biohub.project_participation + ADD CONSTRAINT project_participation_uk2 UNIQUE (system_user_id, project_id); + + ---------------------------------------------------------------------------------------- + -- Update view + ---------------------------------------------------------------------------------------- + + CREATE OR REPLACE VIEW biohub_dapi_v1.project_participation AS SELECT * FROM biohub.project_participation; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +}