diff --git a/api/src/paths/codes.ts b/api/src/paths/codes.ts index eebad437bc..f1bb2673f6 100644 --- a/api/src/paths/codes.ts +++ b/api/src/paths/codes.ts @@ -37,7 +37,8 @@ GET.apiDoc = { 'site_selection_strategies', 'survey_progress', 'method_response_metrics', - 'attractants' + 'attractants', + 'telemetry_device_makes' ], properties: { management_action_type: { @@ -382,6 +383,27 @@ GET.apiDoc = { } } } + }, + telemetry_device_makes: { + type: 'array', + description: 'Active telemetry device manufacturers / makes / vendors.', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'name', 'description'], + properties: { + id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } } } } diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.test.ts deleted file mode 100644 index 56304587a0..0000000000 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.test.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { expect } from 'chai'; -import sinon from 'sinon'; -import { deleteDeployment, getDeploymentById, updateDeployment } from '.'; -import * as db from '../../../../../../../database/db'; -import { HTTPError } from '../../../../../../../errors/http-error'; -import { - BctwDeploymentRecordWithDeviceMeta, - BctwDeploymentService -} from '../../../../../../../services/bctw-service/bctw-deployment-service'; -import { BctwDeviceService } from '../../../../../../../services/bctw-service/bctw-device-service'; -import { CritterbaseService, ICapture } from '../../../../../../../services/critterbase-service'; -import { DeploymentService } from '../../../../../../../services/deployment-service'; -import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; - -describe('getDeploymentById', () => { - afterEach(() => { - sinon.restore(); - }); - - it('Gets an existing deployment', async () => { - const mockDBConnection = getMockDBConnection({ commit: sinon.stub(), release: sinon.stub() }); - const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const mockRemoveDeployment = sinon.stub(DeploymentService.prototype, 'getDeploymentById').resolves({ - deployment_id: 3, - critter_id: 2, - critterbase_critter_id: '333', - bctw_deployment_id: '444', - critterbase_start_capture_id: '555', - critterbase_end_capture_id: null, - critterbase_end_mortality_id: null - }); - const mockBctwService = sinon.stub(BctwDeploymentService.prototype, 'getDeploymentsByIds').resolves([ - { - critter_id: '333', - assignment_id: '666', - collar_id: '777', - attachment_start: '2021-01-01', - attachment_end: '2021-01-02', - deployment_id: '444', - device_id: 888, - created_at: '2021-01-01', - created_by_user_id: '999', - updated_at: null, - updated_by_user_id: null, - valid_from: '2021-01-01', - valid_to: null, - device_make: 17, - device_model: 'model', - frequency: 1, - frequency_unit: 2 - } - ]); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.params = { - projectId: '1', - surveyId: '2', - deploymentId: '3' - }; - - const requestHandler = getDeploymentById(); - - await requestHandler(mockReq, mockRes, mockNext); - - expect(getDBConnectionStub).to.have.been.calledOnce; - expect(mockRemoveDeployment).to.have.been.calledOnce; - expect(mockBctwService).to.have.been.calledOnce; - expect(mockRes.status).to.have.been.calledWith(200); - }); - - it('throws 400 error if no SIMS deployment record matches provided deployment ID', async () => { - const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const mockBCTWDeployments: BctwDeploymentRecordWithDeviceMeta[] = [ - { - critter_id: '333', - assignment_id: 'assignment1', - collar_id: 'collar1', - attachment_start: '2020-01-01', - attachment_end: '2020-01-02', - deployment_id: '444', - device_id: 123, - created_at: '2020-01-01', - created_by_user_id: 'user1', - updated_at: '2020-01-01', - updated_by_user_id: 'user1', - valid_from: '2020-01-01', - valid_to: null, - device_make: 17, - device_model: 'model', - frequency: 1, - frequency_unit: 2 - } - ]; - - const getDeploymentByIdStub = sinon.stub(DeploymentService.prototype, 'getDeploymentById').resolves(); - const getDeploymentsByIdsStub = sinon - .stub(BctwDeploymentService.prototype, 'getDeploymentsByIds') - .resolves(mockBCTWDeployments); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.params = { - projectId: '55', - surveyId: '66', - deploymentId: '77' - }; - - const requestHandler = getDeploymentById(); - - try { - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).message).to.equal('Deployment ID does not exist.'); - expect((actualError as HTTPError).status).to.equal(400); - - expect(getDeploymentByIdStub).calledOnceWith(77); - expect(getDeploymentsByIdsStub).not.to.have.been.called; - expect(mockDBConnection.release).to.have.been.calledOnce; - } - }); - - it('returns bad deployment record if more than 1 active deployment found in BCTW for the SIMS deployment record', async () => { - const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const mockSIMSDeployment = { - deployment_id: 3, - critter_id: 2, - critterbase_critter_id: '333', - bctw_deployment_id: '444', - critterbase_start_capture_id: '555', - critterbase_end_capture_id: null, - critterbase_end_mortality_id: null - }; - - const mockBCTWDeployments: BctwDeploymentRecordWithDeviceMeta[] = [ - { - critter_id: '333', - assignment_id: 'assignment1', - collar_id: 'collar1', - attachment_start: '2020-01-01', - attachment_end: '2020-01-02', - deployment_id: '444', - device_id: 123, - created_at: '2020-01-01', - created_by_user_id: 'user1', - updated_at: '2020-01-01', - updated_by_user_id: 'user1', - valid_from: '2020-01-01', - valid_to: null, - device_make: 17, - device_model: 'model', - frequency: 1, - frequency_unit: 2 - }, - { - critter_id: '333', - assignment_id: 'assignment1', - collar_id: 'collar1', - attachment_start: '2020-01-01', - attachment_end: '2020-01-02', - deployment_id: '444', - device_id: 123, - created_at: '2020-01-01', - created_by_user_id: 'user1', - updated_at: '2020-01-01', - updated_by_user_id: 'user1', - valid_from: '2020-01-01', - valid_to: null, - device_make: 17, - device_model: 'model', - frequency: 1, - frequency_unit: 2 - } - ]; - - const getDeploymentByIdStub = sinon - .stub(DeploymentService.prototype, 'getDeploymentById') - .resolves(mockSIMSDeployment); - const getDeploymentsByIdsStub = sinon - .stub(BctwDeploymentService.prototype, 'getDeploymentsByIds') - .resolves(mockBCTWDeployments); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.params = { - projectId: '55', - surveyId: '66', - deploymentId: '77' - }; - - const requestHandler = getDeploymentById(); - - await requestHandler(mockReq, mockRes, mockNext); - - expect(getDeploymentByIdStub).calledOnceWith(77); - expect(getDeploymentsByIdsStub).calledOnceWith(['444']); - expect(mockRes.json).calledOnceWith({ - deployment: null, - bad_deployment: { - name: 'BCTW Data Error', - message: 'Multiple active deployments found for the same deployment ID, when only one should exist.', - data: { - sims_deployment_id: 3, - bctw_deployment_id: '444' - } - } - }); - expect(mockRes.status).calledOnceWith(200); - expect(mockDBConnection.release).to.have.been.calledOnce; - }); - - it('returns bad deployment record if no active deployment found in BCTW for the SIMS deployment record', async () => { - const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const mockSIMSDeployment = { - deployment_id: 3, - critter_id: 2, - critterbase_critter_id: '333', - bctw_deployment_id: '444', - critterbase_start_capture_id: '555', - critterbase_end_capture_id: null, - critterbase_end_mortality_id: null - }; - - const mockBCTWDeployments: BctwDeploymentRecordWithDeviceMeta[] = [ - { - critter_id: '333', - assignment_id: 'assignment1', - collar_id: 'collar1', - attachment_start: '2020-01-01', - attachment_end: '2020-01-02', - deployment_id: '444_no_match', // different deployment ID - device_id: 123, - created_at: '2020-01-01', - created_by_user_id: 'user1', - updated_at: '2020-01-01', - updated_by_user_id: 'user1', - valid_from: '2020-01-01', - valid_to: null, - device_make: 17, - device_model: 'model', - frequency: 1, - frequency_unit: 2 - } - ]; - - const getDeploymentByIdStub = sinon - .stub(DeploymentService.prototype, 'getDeploymentById') - .resolves(mockSIMSDeployment); - const getDeploymentsByIdsStub = sinon - .stub(BctwDeploymentService.prototype, 'getDeploymentsByIds') - .resolves(mockBCTWDeployments); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.params = { - projectId: '55', - surveyId: '66', - deploymentId: '77' - }; - - const requestHandler = getDeploymentById(); - - await requestHandler(mockReq, mockRes, mockNext); - - expect(getDeploymentByIdStub).calledOnceWith(77); - expect(getDeploymentsByIdsStub).calledOnceWith(['444']); - expect(mockRes.json).calledOnceWith({ - deployment: null, - bad_deployment: { - name: 'BCTW Data Error', - message: 'No active deployments found for deployment ID, when one should exist.', - data: { - sims_deployment_id: 3, - bctw_deployment_id: '444' - } - } - }); - expect(mockRes.status).calledOnceWith(200); - expect(mockDBConnection.release).to.have.been.calledOnce; - }); - - it('catches and re-throws errors', async () => { - const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const mockSIMSDeployment = { - deployment_id: 3, - critter_id: 2, - critterbase_critter_id: '333', - bctw_deployment_id: '444', - critterbase_start_capture_id: '555', - critterbase_end_capture_id: null, - critterbase_end_mortality_id: null - }; - - const mockError = new Error('Test error'); - - const getDeploymentByIdStub = sinon - .stub(DeploymentService.prototype, 'getDeploymentById') - .resolves(mockSIMSDeployment); - const getDeploymentsByIdsStub = sinon - .stub(BctwDeploymentService.prototype, 'getDeploymentsByIds') - .rejects(mockError); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.params = { - projectId: '55', - surveyId: '66', - deploymentId: '77' - }; - - const requestHandler = getDeploymentById(); - - try { - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect(actualError).to.equal(mockError); - expect(getDeploymentByIdStub).calledOnceWith(77); - expect(getDeploymentsByIdsStub).calledOnceWith(['444']); - expect(mockDBConnection.release).to.have.been.calledOnce; - } - }); -}); - -describe('updateDeployment', () => { - afterEach(() => { - sinon.restore(); - }); - - it('updates an existing deployment', async () => { - const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); - const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const mockCapture: ICapture = { - capture_id: '111', - critter_id: '222', - capture_method_id: null, - capture_location_id: '333', - release_location_id: null, - capture_date: '2021-01-01', - capture_time: '12:00:00', - release_date: null, - release_time: null, - capture_comment: null, - release_comment: null - }; - - const mockBctwDeploymentResponse = [ - { - assignment_id: '666', - collar_id: '777', - critter_id: '333', - created_at: '2021-01-01', - created_by_user_id: '999', - updated_at: null, - updated_by_user_id: null, - valid_from: '2021-01-01', - valid_to: null, - attachment_start: '2021-01-01', - attachment_end: '2021-01-02', - deployment_id: '444' - } - ]; - - const updateDeploymentStub = sinon.stub(DeploymentService.prototype, 'updateDeployment').resolves(); - const getCaptureByIdStub = sinon.stub(CritterbaseService.prototype, 'getCaptureById').resolves(mockCapture); - const updateBctwDeploymentStub = sinon - .stub(BctwDeploymentService.prototype, 'updateDeployment') - .resolves(mockBctwDeploymentResponse); - const updateCollarStub = sinon.stub(BctwDeviceService.prototype, 'updateCollar').resolves(); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - const requestHandler = updateDeployment(); - - await requestHandler(mockReq, mockRes, mockNext); - - expect(getDBConnectionStub).to.have.been.calledOnce; - expect(updateDeploymentStub).to.have.been.calledOnce; - expect(getCaptureByIdStub).to.have.been.calledOnce; - expect(updateBctwDeploymentStub).to.have.been.calledOnce; - expect(updateCollarStub).to.have.been.calledOnce; - expect(mockRes.status).to.have.been.calledWith(200); - }); - - it('catches and re-throws errors', async () => { - const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const mockError = new Error('a test error'); - const updateDeploymentStub = sinon.stub(DeploymentService.prototype, 'updateDeployment').rejects(mockError); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - const requestHandler = updateDeployment(); - try { - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect(actualError).to.equal(mockError); - expect(updateDeploymentStub).to.have.been.calledOnce; - } - }); -}); - -describe('deleteDeployment', () => { - afterEach(() => { - sinon.restore(); - }); - - it('deletes an existing deployment', async () => { - const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); - const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const deleteDeploymentStub = sinon - .stub(DeploymentService.prototype, 'deleteDeployment') - .resolves({ bctw_deployment_id: '444' }); - const bctwDeleteDeploymentStub = sinon.stub(BctwDeploymentService.prototype, 'deleteDeployment'); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.params = { - projectId: '1', - surveyId: '2', - deploymentId: '3' - }; - - const requestHandler = deleteDeployment(); - - await requestHandler(mockReq, mockRes, mockNext); - - expect(getDBConnectionStub).to.have.been.calledOnce; - expect(deleteDeploymentStub).to.have.been.calledOnce; - expect(bctwDeleteDeploymentStub).to.have.been.calledOnce; - expect(mockRes.status).to.have.been.calledWith(200); - }); -}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts deleted file mode 100644 index 803c36c84d..0000000000 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts +++ /dev/null @@ -1,593 +0,0 @@ -import dayjs from 'dayjs'; -import { RequestHandler } from 'express'; -import { Operation } from 'express-openapi'; -import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; -import { getDBConnection } from '../../../../../../../database/db'; -import { HTTP400 } from '../../../../../../../errors/http-error'; -import { getDeploymentSchema } from '../../../../../../../openapi/schemas/deployment'; -import { warningSchema } from '../../../../../../../openapi/schemas/warning'; -import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; -import { BctwDeploymentService } from '../../../../../../../services/bctw-service/bctw-deployment-service'; -import { BctwDeviceService } from '../../../../../../../services/bctw-service/bctw-device-service'; -import { getBctwUser } from '../../../../../../../services/bctw-service/bctw-service'; -import { - CritterbaseService, - getCritterbaseUser, - ICritterbaseUser -} from '../../../../../../../services/critterbase-service'; -import { DeploymentService } from '../../../../../../../services/deployment-service'; -import { getLogger } from '../../../../../../../utils/logger'; - -const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index'); - -export const GET: Operation = [ - authorizeRequestHandler((req) => { - return { - or: [ - { - validProjectPermissions: [ - PROJECT_PERMISSION.COORDINATOR, - PROJECT_PERMISSION.COLLABORATOR, - PROJECT_PERMISSION.OBSERVER - ], - surveyId: Number(req.params.surveyId), - discriminator: 'ProjectPermission' - }, - { - validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], - discriminator: 'SystemRole' - } - ] - }; - }), - getDeploymentById() -]; - -GET.apiDoc = { - description: 'Returns information about a specific deployment.', - tags: ['deployment', 'bctw'], - security: [ - { - Bearer: [] - } - ], - parameters: [ - { - in: 'path', - name: 'projectId', - schema: { - type: 'integer', - minimum: 1 - }, - required: true - }, - { - in: 'path', - name: 'surveyId', - schema: { - type: 'integer', - minimum: 1 - }, - required: true - }, - { - in: 'path', - name: 'deploymentId', - description: 'SIMS deployment ID', - schema: { - type: 'integer', - minimum: 1 - }, - required: true - } - ], - responses: { - 200: { - description: 'Responds with information about a deployment under this survey.', - content: { - 'application/json': { - schema: { - type: 'object', - required: ['deployment', 'bad_deployment'], - additionalProperties: false, - properties: { - deployment: { - ...getDeploymentSchema, - nullable: true - }, - bad_deployment: { - ...warningSchema, - nullable: true - } - } - } - } - } - }, - 400: { - $ref: '#/components/responses/400' - }, - 401: { - $ref: '#/components/responses/401' - }, - 403: { - $ref: '#/components/responses/403' - }, - 409: { - $ref: '#/components/responses/409' - }, - 500: { - $ref: '#/components/responses/500' - }, - default: { - $ref: '#/components/responses/default' - } - } -}; - -export function getDeploymentById(): RequestHandler { - return async (req, res) => { - const deploymentId = Number(req.params.deploymentId); - - const connection = getDBConnection(req.keycloak_token); - - try { - await connection.open(); - - const user: ICritterbaseUser = { - keycloak_guid: connection.systemUserGUID(), - username: connection.systemUserIdentifier() - }; - - const deploymentService = new DeploymentService(connection); - const bctwDeploymentService = new BctwDeploymentService(user); - - // Fetch deployments from the deployment service for the given surveyId - const surveyDeployment = await deploymentService.getDeploymentById(deploymentId); - - // Return early if there are no deployments - if (!surveyDeployment) { - // Return 400 if the provided deployment ID does not exist - throw new HTTP400('Deployment ID does not exist.', [{ sims_deployment_id: deploymentId }]); - } - - // Fetch additional deployment details from BCTW service - const bctwDeployments = await bctwDeploymentService.getDeploymentsByIds([surveyDeployment.bctw_deployment_id]); - - // For the SIMS survey deployment record, find the matching BCTW deployment record. - // We expect exactly 1 matching record, otherwise we throw an error. - // More than 1 matching active record indicates an error in the BCTW data. - const matchingBctwDeployments = bctwDeployments.filter( - (deployment) => deployment.deployment_id === surveyDeployment.bctw_deployment_id - ); - - if (matchingBctwDeployments.length > 1) { - defaultLog.warn({ - label: 'getDeploymentById', - message: 'Multiple active deployments found for the same deployment ID, when only one should exist.', - sims_deployment_id: surveyDeployment.deployment_id, - bctw_deployment_id: surveyDeployment.bctw_deployment_id - }); - - const badDeployment = { - name: 'BCTW Data Error', - message: 'Multiple active deployments found for the same deployment ID, when only one should exist.', - data: { - sims_deployment_id: surveyDeployment.deployment_id, - bctw_deployment_id: surveyDeployment.bctw_deployment_id - } - }; - - // Don't continue processing this deployment - return res.status(200).json({ deployment: null, bad_deployment: badDeployment }); - } - - if (matchingBctwDeployments.length === 0) { - defaultLog.warn({ - label: 'getDeploymentById', - message: 'No active deployments found for deployment ID, when one should exist.', - sims_deployment_id: surveyDeployment.deployment_id, - bctw_deployment_id: surveyDeployment.bctw_deployment_id - }); - - const badDeployment = { - name: 'BCTW Data Error', - message: 'No active deployments found for deployment ID, when one should exist.', - data: { - sims_deployment_id: surveyDeployment.deployment_id, - bctw_deployment_id: surveyDeployment.bctw_deployment_id - } - }; - - // Don't continue processing this deployment - return res.status(200).json({ deployment: null, bad_deployment: badDeployment }); - } - - const surveyDeploymentWithBctwData = { - // BCTW properties - assignment_id: matchingBctwDeployments[0].assignment_id, - collar_id: matchingBctwDeployments[0].collar_id, - attachment_start_date: matchingBctwDeployments[0].attachment_start - ? dayjs(matchingBctwDeployments[0].attachment_start).format('YYYY-MM-DD') - : null, - attachment_start_time: matchingBctwDeployments[0].attachment_start - ? dayjs(matchingBctwDeployments[0].attachment_start).format('HH:mm:ss') - : null, - attachment_end_date: matchingBctwDeployments[0].attachment_end - ? dayjs(matchingBctwDeployments[0].attachment_end).format('YYYY-MM-DD') - : null, - attachment_end_time: matchingBctwDeployments[0].attachment_end - ? dayjs(matchingBctwDeployments[0].attachment_end).format('HH:mm:ss') - : null, - bctw_deployment_id: matchingBctwDeployments[0].deployment_id, - device_id: matchingBctwDeployments[0].device_id, - device_make: matchingBctwDeployments[0].device_make, - device_model: matchingBctwDeployments[0].device_model, - frequency: matchingBctwDeployments[0].frequency, - frequency_unit: matchingBctwDeployments[0].frequency_unit, - // SIMS properties - deployment_id: surveyDeployment.deployment_id, - critter_id: surveyDeployment.critter_id, - critterbase_critter_id: surveyDeployment.critterbase_critter_id, - critterbase_start_capture_id: surveyDeployment.critterbase_start_capture_id, - critterbase_end_capture_id: surveyDeployment.critterbase_end_capture_id, - critterbase_end_mortality_id: surveyDeployment.critterbase_end_mortality_id - }; - - return res.status(200).json({ deployment: surveyDeploymentWithBctwData, bad_deployment: null }); - } catch (error) { - defaultLog.error({ label: 'getDeploymentById', message: 'error', error }); - await connection.rollback(); - - throw error; - } finally { - connection.release(); - } - }; -} - -export const PUT: Operation = [ - authorizeRequestHandler((req) => { - return { - or: [ - { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], - surveyId: Number(req.params.surveyId), - discriminator: 'ProjectPermission' - }, - { - validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], - discriminator: 'SystemRole' - } - ] - }; - }), - updateDeployment() -]; - -PUT.apiDoc = { - description: 'Updates information about the start and end of a deployment.', - tags: ['deployment', 'bctw'], - security: [ - { - Bearer: [] - } - ], - parameters: [ - { - in: 'path', - name: 'projectId', - schema: { - type: 'integer', - minimum: 1 - }, - required: true - }, - { - in: 'path', - name: 'surveyId', - schema: { - type: 'integer', - minimum: 1 - }, - required: true - }, - { - in: 'path', - name: 'deploymentId', - description: 'SIMS deployment ID', - schema: { - type: 'integer', - minimum: 1 - }, - required: true - } - ], - requestBody: { - description: 'Specifies a deployment id and the new timerange to update it with.', - content: { - 'application/json': { - schema: { - title: 'Deploy device request object', - type: 'object', - additionalProperties: false, - required: [ - 'critter_id', - 'device_id', - 'attachment_end_date', - 'attachment_end_time', - 'device_make', - 'device_model', - 'frequency', - 'frequency_unit', - 'critterbase_start_capture_id', - 'critterbase_end_capture_id', - 'critterbase_end_mortality_id' - ], - properties: { - critter_id: { - type: 'integer', - minimum: 1 - }, - attachment_end_date: { - type: 'string', - description: 'End date of the deployment, without time.', - nullable: true - }, - attachment_end_time: { - type: 'string', - description: 'End time of the deployment.', - nullable: true - }, - device_id: { - type: 'integer', - minimum: 1 - }, - device_make: { - type: 'number', - nullable: true - }, - device_model: { - type: 'string', - nullable: true - }, - frequency: { - type: 'number', - nullable: true - }, - frequency_unit: { - type: 'number', - nullable: true - }, - critterbase_start_capture_id: { - type: 'string', - description: 'Critterbase capture record when the deployment started', - format: 'uuid', - nullable: true - }, - critterbase_end_capture_id: { - type: 'string', - description: 'Critterbase capture record when the deployment ended', - format: 'uuid', - nullable: true - }, - critterbase_end_mortality_id: { - type: 'string', - description: 'Critterbase mortality record when the deployment ended', - format: 'uuid', - nullable: true - } - } - } - } - } - }, - responses: { - 200: { - description: 'Deployment updated OK.' - }, - 400: { - $ref: '#/components/responses/400' - }, - 401: { - $ref: '#/components/responses/401' - }, - 403: { - $ref: '#/components/responses/403' - }, - 500: { - $ref: '#/components/responses/500' - }, - default: { - $ref: '#/components/responses/default' - } - } -}; - -export function updateDeployment(): RequestHandler { - return async (req, res) => { - const deploymentId = Number(req.params.deploymentId); - - const connection = getDBConnection(req.keycloak_token); - - const { - critter_id, - attachment_end_date, - attachment_end_time, - // device_id, // Do not allow the device_id to be updated - device_make, - device_model, - frequency, - frequency_unit, - critterbase_start_capture_id, - critterbase_end_capture_id, - critterbase_end_mortality_id - } = req.body; - - try { - await connection.open(); - - // Update the deployment in SIMS - const deploymentService = new DeploymentService(connection); - const bctw_deployment_id = await deploymentService.updateDeployment({ - deployment_id: deploymentId, - critter_id: critter_id, - critterbase_start_capture_id, - critterbase_end_capture_id, - critterbase_end_mortality_id - }); - - // TODO: Decide whether to explicitly record attachment start date, or just reference the capture. Might remove this line. - const critterbaseService = new CritterbaseService(getCritterbaseUser(req)); - const capture = await critterbaseService.getCaptureById(critterbase_start_capture_id); - - // Create attachment end date from provided end date (if not null) and end time (if not null). - const attachmentEnd = attachment_end_date - ? attachment_end_time - ? dayjs(`${attachment_end_date} ${attachment_end_time}`).toISOString() - : dayjs(`${attachment_end_date}`).toISOString() - : null; - - // Update the deployment (collar_animal_assignment) in BCTW - const bctwDeploymentService = new BctwDeploymentService(getBctwUser(req)); - // Returns an array though we only expect one record - const bctwDeploymentRecords = await bctwDeploymentService.updateDeployment({ - deployment_id: bctw_deployment_id, - attachment_start: capture.capture_date, - attachment_end: attachmentEnd // TODO: ADD SEPARATE DATE AND TIME TO BCTW - }); - - // Update the collar details in BCTW - const bctwDeviceService = new BctwDeviceService(getBctwUser(req)); - await bctwDeviceService.updateCollar({ - collar_id: bctwDeploymentRecords[0].collar_id, - device_make: device_make, - device_model: device_model, - frequency: frequency, - frequency_unit: frequency_unit - }); - - await connection.commit(); - - return res.status(200).send(); - } catch (error) { - defaultLog.error({ label: 'updateDeployment', message: 'error', error }); - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - }; -} - -export const DELETE: Operation = [ - authorizeRequestHandler((req) => { - return { - or: [ - { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], - surveyId: Number(req.params.surveyId), - discriminator: 'ProjectPermission' - }, - { - validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], - discriminator: 'SystemRole' - } - ] - }; - }), - deleteDeployment() -]; - -DELETE.apiDoc = { - description: 'Deletes the deployment record in SIMS, and soft deletes the record in BCTW.', - tags: ['deploymenty', 'bctw'], - security: [ - { - Bearer: [] - } - ], - parameters: [ - { - in: 'path', - name: 'projectId', - schema: { - type: 'integer', - minimum: 1 - }, - required: true - }, - { - in: 'path', - name: 'surveyId', - schema: { - type: 'integer', - minimum: 1 - }, - required: true - }, - { - in: 'path', - name: 'deploymentId', - description: 'SIMS deployment ID', - schema: { - type: 'integer', - minimum: 1 - }, - required: true - } - ], - responses: { - 200: { - description: 'Delete deployment OK.' - }, - 400: { - $ref: '#/components/responses/400' - }, - 401: { - $ref: '#/components/responses/401' - }, - 403: { - $ref: '#/components/responses/403' - }, - 500: { - $ref: '#/components/responses/500' - }, - default: { - $ref: '#/components/responses/default' - } - } -}; - -export function deleteDeployment(): RequestHandler { - return async (req, res) => { - const deploymentId = Number(req.params.deploymentId); - const surveyId = Number(req.params.surveyId); - - const connection = getDBConnection(req.keycloak_token); - - try { - await connection.open(); - - const user: ICritterbaseUser = { - keycloak_guid: connection.systemUserGUID(), - username: connection.systemUserIdentifier() - }; - - const deploymentService = new DeploymentService(connection); - const { bctw_deployment_id } = await deploymentService.deleteDeployment(surveyId, deploymentId); - - const bctwDeploymentService = new BctwDeploymentService(user); - await bctwDeploymentService.deleteDeployment(bctw_deployment_id); - - await connection.commit(); - return res.status(200).send(); - } catch (error) { - defaultLog.error({ label: 'deleteDeployment', message: 'error', error }); - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - }; -} diff --git a/api/src/paths/telemetry/device/index.test.ts b/api/src/paths/telemetry/device/index.test.ts deleted file mode 100644 index db121c53aa..0000000000 --- a/api/src/paths/telemetry/device/index.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import Ajv from 'ajv'; -import { expect } from 'chai'; -import sinon from 'sinon'; -import { HTTPError } from '../../../errors/http-error'; -import { SystemUser } from '../../../repositories/user-repository'; -import { BctwDeviceService } from '../../../services/bctw-service/bctw-device-service'; -import { getRequestHandlerMocks } from '../../../__mocks__/db'; -import { POST, upsertDevice } from './index'; - -describe('upsertDevice', () => { - afterEach(() => { - sinon.restore(); - }); - - describe('openapi schema', () => { - const ajv = new Ajv(); - - it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema(POST.apiDoc as unknown as object)).to.be.true; - }); - }); - - it('upsert device details', async () => { - const mockUpsertDevice = sinon.stub(BctwDeviceService.prototype, 'updateDevice'); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.system_user = { user_identifier: 'user', user_guid: 'guid' } as SystemUser; - - const requestHandler = upsertDevice(); - - await requestHandler(mockReq, mockRes, mockNext); - - expect(mockRes.statusValue).to.equal(200); - expect(mockUpsertDevice).to.have.been.calledOnce; - }); - - it('catches and re-throws errors', async () => { - const mockError = new Error('a test error'); - const mockBctwService = sinon.stub(BctwDeviceService.prototype, 'updateDevice').rejects(mockError); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.system_user = { user_identifier: 'user', user_guid: 'guid' } as SystemUser; - - const requestHandler = upsertDevice(); - try { - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect((mockError as HTTPError).message).to.eql('a test error'); - expect(mockBctwService).to.have.been.calledOnce; - } - }); -}); diff --git a/api/src/paths/telemetry/device/index.ts b/api/src/paths/telemetry/device/index.ts deleted file mode 100644 index 4d26d80342..0000000000 --- a/api/src/paths/telemetry/device/index.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { RequestHandler } from 'express'; -import { Operation } from 'express-openapi'; -import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; -import { BctwDeviceService } from '../../../services/bctw-service/bctw-device-service'; -import { getBctwUser } from '../../../services/bctw-service/bctw-service'; -import { getLogger } from '../../../utils/logger'; - -const defaultLog = getLogger('paths/telemetry/device/{deviceId}'); - -export const POST: Operation = [ - // TODO: Should this endpoint be guarded such that the user must at the very least belong to a project? - authorizeRequestHandler(() => { - return { - and: [ - { - discriminator: 'SystemUser' - } - ] - }; - }), - upsertDevice() -]; - -POST.apiDoc = { - description: 'Upsert device metadata inside BCTW.', - tags: ['telemetry'], - security: [ - { - Bearer: [] - } - ], - requestBody: { - description: 'Device body', - content: { - 'application/json': { - schema: { - properties: { - collar_id: { - type: 'string', - format: 'uuid' - }, - device_id: { - type: 'integer' - }, - device_make: { - type: 'string' - }, - device_model: { - type: 'string', - nullable: true - }, - frequency: { - type: 'number', - nullable: true - }, - frequency_unit: { - type: 'string', - nullable: true - } - } - } - } - } - }, - responses: { - 200: { - description: 'Resultant object of upsert.', - content: { - 'application/json': { - schema: { - type: 'object' - } - } - } - }, - 400: { - $ref: '#/components/responses/400' - }, - 401: { - $ref: '#/components/responses/401' - }, - 403: { - $ref: '#/components/responses/403' - }, - 500: { - $ref: '#/components/responses/500' - }, - default: { - $ref: '#/components/responses/default' - } - } -}; - -export function upsertDevice(): RequestHandler { - return async (req, res) => { - const user = getBctwUser(req); - - const bctwDeviceService = new BctwDeviceService(user); - try { - const results = await bctwDeviceService.updateDevice(req.body); - return res.status(200).json(results); - } catch (error) { - defaultLog.error({ label: 'upsertDevice', message: 'error', error }); - throw error; - } - }; -} diff --git a/api/src/paths/telemetry/vendors.test.ts b/api/src/paths/telemetry/vendors.test.ts deleted file mode 100644 index 329ea891fe..0000000000 --- a/api/src/paths/telemetry/vendors.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { expect } from 'chai'; -import sinon from 'sinon'; -import { SystemUser } from '../../repositories/user-repository'; -import { BctwDeviceService } from '../../services/bctw-service/bctw-device-service'; -import { getRequestHandlerMocks } from '../../__mocks__/db'; -import { getCollarVendors } from './vendors'; - -describe('getCollarVendors', () => { - afterEach(() => { - sinon.restore(); - }); - - it('gets collar vendors', async () => { - const mockVendors = ['vendor1', 'vendor2']; - const mockGetCollarVendors = sinon.stub(BctwDeviceService.prototype, 'getCollarVendors').resolves(mockVendors); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.system_user = { user_identifier: 'user', user_guid: 'guid' } as SystemUser; - - const requestHandler = getCollarVendors(); - - await requestHandler(mockReq, mockRes, mockNext); - - expect(mockRes.jsonValue).to.eql(mockVendors); - expect(mockRes.statusValue).to.equal(200); - expect(mockGetCollarVendors).to.have.been.calledOnce; - }); - - it('catches and re-throws error', async () => { - const mockError = new Error('a test error'); - - const mockGetCollarVendors = sinon.stub(BctwDeviceService.prototype, 'getCollarVendors').rejects(mockError); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.system_user = { user_identifier: 'user', user_guid: 'guid' } as SystemUser; - - const requestHandler = getCollarVendors(); - - try { - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect(actualError).to.equal(mockError); - expect(mockGetCollarVendors).to.have.been.calledOnce; - } - }); -}); diff --git a/api/src/paths/telemetry/vendors.ts b/api/src/paths/telemetry/vendors.ts deleted file mode 100644 index e9ea0e3561..0000000000 --- a/api/src/paths/telemetry/vendors.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { RequestHandler } from 'express'; -import { Operation } from 'express-openapi'; -import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; -import { BctwDeviceService } from '../../services/bctw-service/bctw-device-service'; -import { getBctwUser } from '../../services/bctw-service/bctw-service'; -import { getLogger } from '../../utils/logger'; - -const defaultLog = getLogger('paths/telemetry/vendors'); - -export const GET: Operation = [ - authorizeRequestHandler(() => { - return { - and: [ - { - discriminator: 'SystemUser' - } - ] - }; - }), - getCollarVendors() -]; - -GET.apiDoc = { - description: 'Get a list of supported collar vendors.', - tags: ['telemetry'], - security: [ - { - Bearer: [] - } - ], - responses: { - 200: { - description: 'Collar vendors response object.', - content: { - 'application/json': { - schema: { - type: 'array', - items: { - type: 'string' - } - } - } - } - }, - 400: { - $ref: '#/components/responses/400' - }, - 401: { - $ref: '#/components/responses/401' - }, - 403: { - $ref: '#/components/responses/403' - }, - 500: { - $ref: '#/components/responses/500' - }, - default: { - $ref: '#/components/responses/default' - } - } -}; - -export function getCollarVendors(): RequestHandler { - return async (req, res) => { - const user = getBctwUser(req); - - const bctwDeviceService = new BctwDeviceService(user); - - try { - const result = await bctwDeviceService.getCollarVendors(); - return res.status(200).json(result); - } catch (error) { - defaultLog.error({ label: 'getCollarVendors', message: 'error', error }); - throw error; - } - }; -} diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index 07c079cda4..678567aad0 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -25,6 +25,7 @@ const SurveyProgressCode = ICode.extend({ description: z.string() }); const MethodResponseMetricsCode = ICode.extend({ description: z.string() }); const AttractantCode = ICode.extend({ description: z.string() }); const ObservationSubcountSignCode = ICode.extend({ description: z.string() }); +const DeviceMakeCode = ICode.extend({ description: z.string() }); export const IAllCodeSets = z.object({ management_action_type: CodeSet(), @@ -46,7 +47,8 @@ export const IAllCodeSets = z.object({ survey_progress: CodeSet(SurveyProgressCode.shape), method_response_metrics: CodeSet(MethodResponseMetricsCode.shape), attractants: CodeSet(AttractantCode.shape), - observation_subcount_signs: CodeSet(ObservationSubcountSignCode.shape) + observation_subcount_signs: CodeSet(ObservationSubcountSignCode.shape), + telemetry_device_makes: CodeSet(DeviceMakeCode.shape) }); export type IAllCodeSets = z.infer; @@ -59,9 +61,9 @@ export class CodeRepository extends BaseRepository { */ async getSampleMethods() { const sql = SQL` - SELECT - method_lookup_id as id, - name, + SELECT + method_lookup_id as id, + name, description FROM method_lookup ORDER BY name ASC; @@ -104,7 +106,7 @@ export class CodeRepository extends BaseRepository { first_nations_id as id, name FROM first_nations - WHERE record_end_date is null + WHERE record_end_date is null ORDER BY name ASC; `; @@ -125,7 +127,7 @@ export class CodeRepository extends BaseRepository { agency_id as id, name FROM agency - WHERE record_end_date is null + WHERE record_end_date is null ORDER BY name ASC; `; @@ -144,7 +146,7 @@ export class CodeRepository extends BaseRepository { const sqlStatement = SQL` SELECT proprietor_type_id as id, - name, + name, is_first_nation FROM proprietor_type WHERE record_end_date is null; @@ -186,7 +188,7 @@ export class CodeRepository extends BaseRepository { const sqlStatement = SQL` SELECT intended_outcome_id as id, - name, + name, description FROM intended_outcome WHERE record_end_date is null; @@ -210,7 +212,7 @@ export class CodeRepository extends BaseRepository { agency_id, name FROM investment_action_category - WHERE record_end_date is null + WHERE record_end_date is null ORDER BY name ASC; `; @@ -314,7 +316,7 @@ export class CodeRepository extends BaseRepository { name FROM project_role WHERE record_end_date is null - ORDER BY + ORDER BY CASE WHEN name = 'Coordinator' THEN 0 ELSE 1 END; `; @@ -466,4 +468,27 @@ export class CodeRepository extends BaseRepository { return response.rows; } + + /** + * Get active telemetry device makes. + * + * @return {*} + * @memberof CodeRepository + */ + async getActiveTelemetryDeviceMakes() { + const sqlStatement = SQL` + SELECT + device_make_id as id, + name, + description + FROM device_make + WHERE + record_effective_date IS NOT NULL + AND record_end_date is null; + `; + + const response = await this.connection.sql(sqlStatement, DeviceMakeCode); + + return response.rows; + } } diff --git a/api/src/services/bctw-service/bctw-device-service.ts b/api/src/services/bctw-service/bctw-device-service.ts deleted file mode 100644 index fe6f0114e5..0000000000 --- a/api/src/services/bctw-service/bctw-device-service.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { BctwDeployDevice } from './bctw-deployment-service'; -import { BctwService } from './bctw-service'; - -export type BctwDevice = Omit & { - collar_id: string; -}; - -export type BctwUpdateCollarRequest = { - /** - * The primary ID (uuid) of the collar record to update. - */ - collar_id: string; - device_make?: number | null; - device_model?: string | null; - frequency?: number | null; - frequency_unit?: number | null; -}; - -// BCTW-MIGRATION-TODO: DEPRECATED -export class BctwDeviceService extends BctwService { - /** - * Get a list of all supported collar vendors. - * - * TODO: unused? - * - * @return {*} {Promise} - * @memberof BctwDeviceService - */ - async getCollarVendors(): Promise { - const { data } = await this.axiosInstance.get('/get-collar-vendors'); - - return data; - } - - /** - * Get device hardware details by device id and device make. - * - * TODO: unused? - * - * @param {number} deviceId - * @param {deviceMake} deviceMake - * @returns {*} {Promise} - * @memberof BctwService - */ - async getDeviceDetails(deviceId: number, deviceMake: string): Promise { - const { data } = await this.axiosInstance.get(`/get-collar-history-by-device/${deviceId}`, { - params: { make: deviceMake } - }); - - return data; - } - - /** - * Update device hardware details in BCTW. - * - * @param {BctwDevice} device - * @returns {*} {BctwDevice} - * @memberof BctwService - */ - async updateDevice(device: BctwDevice): Promise { - const { data } = await this.axiosInstance.post('/upsert-collar', device); - - if (data?.errors?.length) { - throw Error(JSON.stringify(data.errors)); - } - - return data; - } - - /** - * Update collar details in BCTW. - * - * @param {BctwUpdateCollarRequest} collar - The collar details to update. - * @return {*} {Promise} - * @memberof BctwDeviceService - */ - async updateCollar(collar: BctwUpdateCollarRequest): Promise { - const { data } = await this.axiosInstance.patch('/update-collar', collar); - - if (data?.errors?.length) { - throw Error(JSON.stringify(data.errors)); - } - - return data; - } -} diff --git a/api/src/services/code-service.test.ts b/api/src/services/code-service.test.ts index 9abf57fcee..6a842ba58c 100644 --- a/api/src/services/code-service.test.ts +++ b/api/src/services/code-service.test.ts @@ -45,7 +45,8 @@ describe('CodeService', () => { 'sample_methods', 'survey_progress', 'method_response_metrics', - 'observation_subcount_signs' + 'observation_subcount_signs', + 'telemetry_device_makes' ); }); }); diff --git a/api/src/services/code-service.ts b/api/src/services/code-service.ts index 715193a3ba..3de4d706a9 100644 --- a/api/src/services/code-service.ts +++ b/api/src/services/code-service.ts @@ -44,7 +44,8 @@ export class CodeService extends DBService { survey_progress, method_response_metrics, attractants, - observation_subcount_signs + observation_subcount_signs, + telemetry_device_makes ] = await Promise.all([ await this.codeRepository.getManagementActionType(), await this.codeRepository.getFirstNations(), @@ -65,7 +66,8 @@ export class CodeService extends DBService { await this.codeRepository.getSurveyProgress(), await this.codeRepository.getMethodResponseMetrics(), await this.codeRepository.getAttractants(), - await this.codeRepository.getObservationSubcountSigns() + await this.codeRepository.getObservationSubcountSigns(), + await this.codeRepository.getActiveTelemetryDeviceMakes() ]); return { @@ -88,7 +90,8 @@ export class CodeService extends DBService { survey_progress, method_response_metrics, attractants, - observation_subcount_signs + observation_subcount_signs, + telemetry_device_makes }; }