From 90de834305c0335ecc88e68b12162847c4cd249e Mon Sep 17 00:00:00 2001 From: Mac Deluca <99926243+MacQSL@users.noreply.github.com> Date: Mon, 7 Oct 2024 14:49:57 -0700 Subject: [PATCH] SIMSBIOHUB-606: Upload Capture Attachments (#1388) - Upload capture attachments - Upload capture attachments and remove during edit - Download attachments --- api/src/constants/attachments.ts | 17 ++ .../critter_capture_attachment.ts | 48 ++++ .../critter_mortality_attachment.ts | 48 ++++ .../{attachmentId}/getSignedUrl.ts | 55 ++-- .../attachments/index.test.ts | 58 +++++ .../attachments/index.ts | 136 ++++++++++ .../attachments/upload.test.ts | 90 +++++++ .../attachments/upload.ts | 213 +++++++++++++++ .../{surveyId}/critters/{critterId}/index.ts | 16 +- ...critter-attachment-repository.interface.ts | 15 ++ .../critter-attachment-repository.test.ts | 136 ++++++++++ .../critter-attachment-repository.ts | 246 ++++++++++++++++++ api/src/services/attachment-service.ts | 6 +- .../critter-attachment-service.test.ts | 104 ++++++++ .../services/critter-attachment-service.ts | 106 ++++++++ api/src/utils/file-utils.test.ts | 39 +++ api/src/utils/file-utils.ts | 132 +++++++++- api/src/utils/logger.ts | 4 +- .../attachments/AttachmentTableDropzone.tsx | 148 +++++++++++ .../GenericGridColumnDefinitions.tsx | 55 +++- app/src/components/file-upload/FileUpload.tsx | 4 - .../captures/AnimalCaptureContainer.tsx | 97 +++++-- .../components/AnimalCaptureForm.tsx | 103 +++++++- .../capture-form/create/CreateCapturePage.tsx | 118 +++++---- .../capture-form/edit/EditCapturePage.tsx | 59 ++++- .../components/AnimalCaptureCardContainer.tsx | 2 +- .../AnimalMortalityCardContainer.tsx | 2 +- .../mortality-form/edit/EditMortalityPage.tsx | 19 +- .../surveys/view/survey-animals/animal.ts | 18 +- app/src/hooks/api/useAnimalApi.ts | 73 +++++- app/src/hooks/useS3Download.tsx | 41 +++ app/src/interfaces/useCritterApi.interface.ts | 44 ++++ ...000_capture_mortality_attachment_tables.ts | 158 +++++++++++ 33 files changed, 2281 insertions(+), 129 deletions(-) create mode 100644 api/src/database-models/critter_capture_attachment.ts create mode 100644 api/src/database-models/critter_mortality_attachment.ts create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/index.test.ts create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/index.ts create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload.test.ts create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload.ts create mode 100644 api/src/repositories/critter-attachment-repository.interface.ts create mode 100644 api/src/repositories/critter-attachment-repository.test.ts create mode 100644 api/src/repositories/critter-attachment-repository.ts create mode 100644 api/src/services/critter-attachment-service.test.ts create mode 100644 api/src/services/critter-attachment-service.ts create mode 100644 app/src/components/attachments/AttachmentTableDropzone.tsx create mode 100644 app/src/hooks/useS3Download.tsx create mode 100644 database/src/migrations/20240905000000_capture_mortality_attachment_tables.ts diff --git a/api/src/constants/attachments.ts b/api/src/constants/attachments.ts index 0dd2f7f738..030ff6531e 100644 --- a/api/src/constants/attachments.ts +++ b/api/src/constants/attachments.ts @@ -18,6 +18,9 @@ export enum ATTACHMENT_TYPE { export enum TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE { /** * Lotek API key file type. + * + * @export + * @enum {string} */ KEYX = 'KeyX', /** @@ -25,3 +28,17 @@ export enum TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE { */ CFG = 'Cfg' } + +export enum CRITTER_CAPTURE_ATTACHMENT_TYPE { + /** + * Critter Capture Attachment file type. + * + * Note: This will not be used as the attachment type on the record. + * But used to identify which service to get the S3 key from in the endpoint. + * + * @export + * @enum {string} + */ + CAPTURE = 'Capture', + MORTALITY = 'Mortality' +} diff --git a/api/src/database-models/critter_capture_attachment.ts b/api/src/database-models/critter_capture_attachment.ts new file mode 100644 index 0000000000..9901c37da6 --- /dev/null +++ b/api/src/database-models/critter_capture_attachment.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +/** + * Note: These files should only contain the `Data Models` and `Data Records` with equivalent inferred types. + * + * Data Models contain a 1 to 1 mapping of the database table. + * + * Data Records contain a 1 to 1 mapping of the database table, minus the audit columns. + */ + +/** + * Critter Capture Attachment Model. + * + * @description Data model for `critter_capture_attachment`. + */ +export const CritterCaptureAttachmentModel = z.object({ + critter_capture_attachment_id: z.number(), + uuid: z.string().nullable(), + critter_id: z.number(), + critterbase_capture_id: z.string(), + file_type: z.string(), + file_name: z.string().nullable(), + file_size: z.number().nullable(), + title: z.string().nullable(), + description: z.string().nullable(), + key: z.string(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type CritterCaptureAttachmentModel = z.infer; + +/** + * Critter Capture Attachment Record. + * + * @description Data record for `critter_capture_attachment`. + */ +export const CritterCaptureAttachmentRecord = CritterCaptureAttachmentModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type CritterCaptureAttachmentRecord = z.infer; diff --git a/api/src/database-models/critter_mortality_attachment.ts b/api/src/database-models/critter_mortality_attachment.ts new file mode 100644 index 0000000000..f643cbf272 --- /dev/null +++ b/api/src/database-models/critter_mortality_attachment.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +/** + * Note: These files should only contain the `Data Models` and `Data Records` with equivalent inferred types. + * + * Data Models contain a 1 to 1 mapping of the database table. + * + * Data Records contain a 1 to 1 mapping of the database table, minus the audit columns. + */ + +/** + * Critter Mortality Attachment Model. + * + * @description Data model for `critter_mortality_attachment`. + */ +export const CritterMortalityAttachmentModel = z.object({ + critter_mortality_attachment_id: z.number(), + uuid: z.string().nullable(), + critter_id: z.string(), + critterbase_mortality_id: z.string(), + file_type: z.string(), + file_name: z.string().nullable(), + file_size: z.number().nullable(), + title: z.string().nullable(), + description: z.string().nullable(), + key: z.string(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type CritterMortalityAttachmentModel = z.infer; + +/** + * Critter Mortality Attachment Record. + * + * @description Data record for `critter_mortality_attachment`. + */ +export const CritterMortalityAttachmentRecord = CritterMortalityAttachmentModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type CritterMortalityAttachmentRecord = z.infer; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.ts index 5bd8c215a6..beb81d9482 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.ts @@ -1,10 +1,15 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; -import { ATTACHMENT_TYPE, TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE } from '../../../../../../../constants/attachments'; +import { + ATTACHMENT_TYPE, + CRITTER_CAPTURE_ATTACHMENT_TYPE, + TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE +} from '../../../../../../../constants/attachments'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../../database/db'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; import { AttachmentService } from '../../../../../../../services/attachment-service'; +import { CritterAttachmentService } from '../../../../../../../services/critter-attachment-service'; import { getS3SignedURL } from '../../../../../../../utils/file-utils'; import { getLogger } from '../../../../../../../utils/logger'; @@ -74,7 +79,14 @@ GET.apiDoc = { name: 'attachmentType', schema: { type: 'string', - enum: ['Report', 'KeyX', 'Cfg', 'Other'] + enum: [ + ATTACHMENT_TYPE.REPORT, + ATTACHMENT_TYPE.OTHER, + TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG, + TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX, + CRITTER_CAPTURE_ATTACHMENT_TYPE.CAPTURE, + CRITTER_CAPTURE_ATTACHMENT_TYPE.MORTALITY + ] }, required: true } @@ -118,6 +130,10 @@ export function getSurveyAttachmentSignedURL(): RequestHandler { req_body: req.body }); + const surveyId = Number(req.params.surveyId); + const attachmentId = Number(req.params.attachmentId); + const attachmentType = req.query.attachmentType; + const connection = getDBConnection(req.keycloak_token); try { @@ -126,26 +142,25 @@ export function getSurveyAttachmentSignedURL(): RequestHandler { let s3Key; const attachmentService = new AttachmentService(connection); + const critterAttachmentService = new CritterAttachmentService(connection); - if (req.query.attachmentType === ATTACHMENT_TYPE.REPORT) { - s3Key = await attachmentService.getSurveyReportAttachmentS3Key( - Number(req.params.surveyId), - Number(req.params.attachmentId) - ); - } else if ( - req.query.attachmentType === TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX || - req.query.attachmentType === TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG - ) { - s3Key = await attachmentService.getSurveyTelemetryCredentialAttachmentS3Key( - Number(req.params.surveyId), - Number(req.params.attachmentId) - ); - } else { - s3Key = await attachmentService.getSurveyAttachmentS3Key( - Number(req.params.surveyId), - Number(req.params.attachmentId) - ); + switch (attachmentType) { + case CRITTER_CAPTURE_ATTACHMENT_TYPE.CAPTURE: + s3Key = await critterAttachmentService.getCritterCaptureAttachmentS3Key(surveyId, attachmentId); + break; + case ATTACHMENT_TYPE.REPORT: + s3Key = await attachmentService.getSurveyReportAttachmentS3Key(surveyId, attachmentId); + break; + case TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX: + s3Key = await attachmentService.getSurveyTelemetryCredentialAttachmentS3Key(surveyId, attachmentId); + break; + case TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG: + s3Key = await attachmentService.getSurveyTelemetryCredentialAttachmentS3Key(surveyId, attachmentId); + break; + default: + s3Key = await attachmentService.getSurveyAttachmentS3Key(surveyId, attachmentId); } + await connection.commit(); const s3SignedUrl = await getS3SignedURL(s3Key); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/index.test.ts new file mode 100644 index 0000000000..97da2f9659 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/index.test.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { deleteCritterCaptureAttachments } from '.'; +import * as db from '../../../../../../../../../../database/db'; +import { CritterAttachmentService } from '../../../../../../../../../../services/critter-attachment-service'; +import * as S3 from '../../../../../../../../../../utils/file-utils'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../../../../__mocks__/db'; + +describe('deleteCritterCaptureAttachments', () => { + afterEach(() => { + sinon.restore(); + }); + + it('deletes all attachments for a critter capture', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub(), + rollback: sinon.stub() + }); + + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockFindAttachments = sinon.stub(CritterAttachmentService.prototype, 'findAllCritterCaptureAttachments'); + const mockDeleteAttachments = sinon.stub(CritterAttachmentService.prototype, 'deleteCritterCaptureAttachments'); + const mockBulkDeleteFilesFromS3 = sinon.stub(S3, 'bulkDeleteFilesFromS3'); + + mockFindAttachments.resolves([{ critter_capture_attachment_id: 1, key: 'DELETE_S3_KEY' }] as any[]); + mockDeleteAttachments.resolves(['DELETE_S3_KEY']); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2', + critterId: '3', + critterbaseCaptureId: '123e4567-e89b-12d3-a456-426614174000' + }; + + const requestHandler = deleteCritterCaptureAttachments(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(mockDBConnection.open).to.have.been.calledOnce; + + expect(mockFindAttachments).to.have.been.calledOnceWithExactly(2, '123e4567-e89b-12d3-a456-426614174000'); + expect(mockDeleteAttachments).to.have.been.calledOnceWithExactly(2, [1]); + + expect(mockBulkDeleteFilesFromS3).to.have.been.calledOnceWithExactly(['DELETE_S3_KEY']); + + expect(mockRes.status).to.have.been.calledWith(200); + expect(mockRes.send).to.have.been.calledWith(); + + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect(mockDBConnection.rollback).to.not.have.been.called; + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/index.ts new file mode 100644 index 0000000000..86dd273bbf --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/index.ts @@ -0,0 +1,136 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../../../../database/db'; +import { authorizeRequestHandler } from '../../../../../../../../../../request-handlers/security/authorization'; +import { CritterAttachmentService } from '../../../../../../../../../../services/critter-attachment-service'; +import { bulkDeleteFilesFromS3 } from '../../../../../../../../../../utils/file-utils'; +import { getLogger } from '../../../../../../../../../../utils/logger'; + +const defaultLog = getLogger( + '/api/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments' +); + +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' + } + ] + }; + }), + deleteCritterCaptureAttachments() +]; + +DELETE.apiDoc = { + description: 'Delete all attachments for a critter capture.', + tags: ['attachment'], + 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: 'critterId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'critterbaseCaptureId', + schema: { + type: 'string', + format: 'uuid' + }, + required: true + } + ], + responses: { + 200: { + description: 'Delete OK' + }, + 401: { + $ref: '#/components/responses/401' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Delete all attachments for a critter capture. + * + * @returns {RequestHandler} + */ +export function deleteCritterCaptureAttachments(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + const critterbaseCaptureId = req.params.critterbaseCaptureId; + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const critterAttachmentService = new CritterAttachmentService(connection); + + // Get all attachments for the critter capture + const attachments = await critterAttachmentService.findAllCritterCaptureAttachments( + surveyId, + critterbaseCaptureId + ); + + // Get the S3 keys and attachmentIds for the attachments + const s3Keys = attachments.map((attachment) => attachment.key); + const attachmentIds = attachments.map((attachment) => attachment.critter_capture_attachment_id); + + // Delete the attachments from the database + await critterAttachmentService.deleteCritterCaptureAttachments(surveyId, attachmentIds); + + // Delete the attachments from S3 + await bulkDeleteFilesFromS3(s3Keys); + + await connection.commit(); + + return res.status(200).send(); + } catch (error) { + defaultLog.error({ label: 'deleteCritterCaptureAttachments', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload.test.ts new file mode 100644 index 0000000000..06c36e58bd --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload.test.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as db from '../../../../../../../../../../database/db'; +import { CritterAttachmentService } from '../../../../../../../../../../services/critter-attachment-service'; +import * as S3 from '../../../../../../../../../../utils/file-utils'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../../../../__mocks__/db'; +import { uploadCaptureAttachments } from './upload'; + +describe('uploadCaptureAttachments', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockFile = { + fieldname: 'media', + originalname: 'test.txt', + encoding: '7bit', + mimetype: 'text/plain', + size: 1024, + buffer: Buffer.from('test') + }; + + it('creates attachments and deletes any attachments from a list of ids', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub(), + rollback: sinon.stub() + }); + + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockUpsertAttachment = sinon.stub(CritterAttachmentService.prototype, 'upsertCritterCaptureAttachment'); + const mockDeleteAttachments = sinon.stub(CritterAttachmentService.prototype, 'deleteCritterCaptureAttachments'); + const mockS3UploadFileToS3 = sinon.stub(S3, 'uploadFileToS3'); + const mockS3GenerateS3FileKey = sinon.stub(S3, 'generateS3FileKey').returns('S3KEY'); + const mockBulkDeleteFilesFromS3 = sinon.stub(S3, 'bulkDeleteFilesFromS3'); + + mockUpsertAttachment.resolves({ critter_capture_attachment_id: 1, key: 'S3KEY' }); + mockDeleteAttachments.resolves(['DELETE_S3_KEY']); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.files = [mockFile as Express.Multer.File]; + mockReq.body = { + delete_ids: ['1', '2'] + }; + mockReq.params = { + projectId: '1', + surveyId: '2', + critterId: '3', + critterbaseCaptureId: '123e4567-e89b-12d3-a456-426614174000' + }; + + const requestHandler = uploadCaptureAttachments(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(mockDBConnection.open).to.have.been.calledOnce; + + expect(mockDeleteAttachments).to.have.been.calledOnceWithExactly(2, [1, 2]); + expect(mockBulkDeleteFilesFromS3).to.have.been.calledOnceWithExactly(['DELETE_S3_KEY']); + + expect(mockS3GenerateS3FileKey).to.have.been.calledOnceWithExactly({ + projectId: 1, + surveyId: 2, + critterId: 3, + folder: 'captures', + critterbaseCaptureId: '123e4567-e89b-12d3-a456-426614174000', + fileName: 'test.txt' + }); + + expect(mockUpsertAttachment).to.have.been.calledOnceWithExactly({ + critter_id: 3, + critterbase_capture_id: '123e4567-e89b-12d3-a456-426614174000', + file_name: 'test.txt', + file_size: 1024, + key: 'S3KEY' + }); + + expect(mockS3UploadFileToS3).to.have.been.calledWith(mockFile, 'S3KEY'); + + expect(mockRes.status).to.have.been.calledWith(200); + expect(mockRes.json).to.have.been.calledWith({ attachment_ids: [1] }); + + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect(mockDBConnection.rollback).to.not.have.been.called; + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload.ts new file mode 100644 index 0000000000..c85cb55658 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload.ts @@ -0,0 +1,213 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../../../../database/db'; +import { fileSchema } from '../../../../../../../../../../openapi/schemas/file'; +import { authorizeRequestHandler } from '../../../../../../../../../../request-handlers/security/authorization'; +import { CritterAttachmentService } from '../../../../../../../../../../services/critter-attachment-service'; +import { + bulkDeleteFilesFromS3, + generateS3FileKey, + uploadFileToS3 +} from '../../../../../../../../../../utils/file-utils'; +import { getLogger } from '../../../../../../../../../../utils/logger'; + +const defaultLog = getLogger( + '/api/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload' +); + +export const POST: 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' + } + ] + }; + }), + uploadCaptureAttachments() +]; + +POST.apiDoc = { + description: 'Upload a Critter capture-specific attachment.', + tags: ['attachment'], + 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: 'critterId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'critterbaseCaptureId', + schema: { + type: 'string', + format: 'uuid' + }, + required: true + } + ], + requestBody: { + description: 'Attachment upload post request object.', + content: { + 'multipart/form-data': { + schema: { + type: 'object', + required: ['media'], + additionalProperties: false, + properties: { + media: { + description: 'Uploaded Capture attachments.', + type: 'array', + items: fileSchema + }, + delete_ids: { + description: 'Critter Capture Attachment IDs to delete.', + type: 'array', + items: { + type: 'string', + format: 'integer' + } + } + } + } + } + } + }, + responses: { + 200: { + description: 'Successfull upload response.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['attachment_ids'], + additionalProperties: false, + properties: { + attachment_ids: { + description: 'The IDs of the capture attachments that were uploaded.', + type: 'array', + items: { + type: 'integer', + minItems: 1 + } + } + } + } + } + } + }, + 401: { + $ref: '#/components/responses/401' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Uploads any media in the request to S3, adding their keys to the request. + * Optionally deletes any attachments flagged for deletion. + * + * @returns {RequestHandler} + */ +export function uploadCaptureAttachments(): RequestHandler { + return async (req, res) => { + const rawMediaFiles = req.files as Express.Multer.File[]; + const deleteIds: number[] = req.body.delete_ids?.map(Number) ?? []; + const projectId = Number(req.params.projectId); + const surveyId = Number(req.params.surveyId); + const critterId = Number(req.params.critterId); + const critterbaseCaptureId = req.params.critterbaseCaptureId; + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const critterAttachmentService = new CritterAttachmentService(connection); + + // Delete any flagged attachments + if (deleteIds.length) { + // Delete the attachments from the database and get the S3 keys + const s3Keys = await critterAttachmentService.deleteCritterCaptureAttachments(surveyId, deleteIds); + // Bulk delete the files from S3 + await bulkDeleteFilesFromS3(s3Keys); + } + + // Upload each file to S3 and store the file details in the database + const uploadPromises = rawMediaFiles.map(async (file) => { + // Generate the S3 key for the file - used only on new inserts + const s3Key = generateS3FileKey({ + projectId: projectId, + surveyId: surveyId, + critterId: critterId, + folder: 'captures', + critterbaseCaptureId: critterbaseCaptureId, + fileName: file.originalname + }); + + // Store the file details in the database + const upsertResult = await critterAttachmentService.upsertCritterCaptureAttachment({ + critter_id: critterId, + critterbase_capture_id: critterbaseCaptureId, + file_name: file.originalname, + file_size: file.size, + key: s3Key + }); + + await uploadFileToS3(file, upsertResult.key); + + return upsertResult.critter_capture_attachment_id; + }); + + // In parallel, upload all the files to S3 and store the file details in the database + const attachmentIds = await Promise.all(uploadPromises); + + await connection.commit(); + + return res.status(200).json({ attachment_ids: attachmentIds }); + } catch (error) { + defaultLog.error({ label: 'uploadCaptureAttachments', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts index 3c22b02747..0b66a3dc75 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts @@ -6,6 +6,7 @@ import { HTTPError, HTTPErrorType } from '../../../../../../../errors/http-error import { bulkUpdateResponse, critterBulkRequestObject } from '../../../../../../../openapi/schemas/critter'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; import { getBctwUser } from '../../../../../../../services/bctw-service/bctw-service'; +import { CritterAttachmentService } from '../../../../../../../services/critter-attachment-service'; import { CritterbaseService, ICritterbaseUser } from '../../../../../../../services/critterbase-service'; import { SurveyCritterService } from '../../../../../../../services/survey-critter-service'; import { getLogger } from '../../../../../../../utils/logger'; @@ -220,6 +221,7 @@ export function getCrittersFromSurvey(): RequestHandler { const surveyService = new SurveyCritterService(connection); const critterbaseService = new CritterbaseService(user); + const critterAttachmentService = new CritterAttachmentService(connection); const surveyCritter = await surveyService.getCritterById(surveyId, critterId); @@ -227,7 +229,11 @@ export function getCrittersFromSurvey(): RequestHandler { return res.status(404).json({ error: `Critter with id ${critterId} not found.` }); } - const critterbaseCritter = await critterbaseService.getCritter(surveyCritter.critterbase_critter_id); + // Get the attachments from SIMS table and the Critter from critterbase + const [atttachments, critterbaseCritter] = await Promise.all([ + critterAttachmentService.findAllCritterAttachments(surveyCritter.critter_id), + critterbaseService.getCritter(surveyCritter.critterbase_critter_id) + ]); if (!critterbaseCritter || critterbaseCritter.length === 0) { return res.status(404).json({ error: `Critter ${surveyCritter.critterbase_critter_id} not found.` }); @@ -237,12 +243,16 @@ export function getCrittersFromSurvey(): RequestHandler { ...surveyCritter, ...critterbaseCritter, critterbase_critter_id: surveyCritter.critterbase_critter_id, - critter_id: surveyCritter.critter_id + critter_id: surveyCritter.critter_id, + attachments: { + capture_attachments: atttachments.captureAttachments + // TODO: add mortality attachments + } }; return res.status(200).json(critterMapped); } catch (error) { - defaultLog.error({ label: 'createCritter', message: 'error', error }); + defaultLog.error({ label: 'getCritter', message: 'error', error }); await connection.rollback(); throw error; } finally { diff --git a/api/src/repositories/critter-attachment-repository.interface.ts b/api/src/repositories/critter-attachment-repository.interface.ts new file mode 100644 index 0000000000..098df91695 --- /dev/null +++ b/api/src/repositories/critter-attachment-repository.interface.ts @@ -0,0 +1,15 @@ +export type CritterCaptureAttachmentPayload = { + critter_id: number; + critterbase_capture_id: string; + file_name: string; + file_size: number; + key: string; +}; + +export type CritterMortalityAttachmentPayload = { + critter_id: number; + critterbase_mortality_id: string; + file_name: string; + file_size: number; + key: string; +}; diff --git a/api/src/repositories/critter-attachment-repository.test.ts b/api/src/repositories/critter-attachment-repository.test.ts new file mode 100644 index 0000000000..1dc3e7e0c3 --- /dev/null +++ b/api/src/repositories/critter-attachment-repository.test.ts @@ -0,0 +1,136 @@ +import chai, { expect } from 'chai'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMockDBConnection } from '../__mocks__/db'; +import { CritterAttachmentRepository } from './critter-attachment-repository'; + +chai.use(sinonChai); + +describe('CritterAttachmentRepository', () => { + describe('getCritterCaptureAttachmentS3Key', () => { + it('gets S3 key', async () => { + const mockResponse = { rows: [{ key: 'key' }], rowCount: 1 } as any as Promise>; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + + const result = await service.getCritterCaptureAttachmentS3Key(1, 1); + + expect(mockConnection.sql).to.have.been.calledOnce; + expect(result).to.be.equal('key'); + }); + + it('throws error when no rows are returned', async () => { + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + + try { + await service.getCritterCaptureAttachmentS3Key(1, 1); + } catch (error: any) { + expect(error.message).to.be.equal('Failed to get critter capture attachment signed URL'); + } + }); + }); + + describe('upsertCritterCaptureAttachment', () => { + it('upserts attachment', async () => { + const mockResponse = { rows: [{ critter_capture_attachment_id: 1, key: 'key' }], rowCount: 1 } as any as Promise< + QueryResult + >; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + + const result = await service.upsertCritterCaptureAttachment({} as any); + + expect(mockConnection.sql).to.have.been.calledOnce; + expect(result).to.be.deep.equal({ critter_capture_attachment_id: 1, key: 'key' }); + }); + + it('throws error when no rows are returned', async () => { + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + + try { + await service.upsertCritterCaptureAttachment({} as any); + } catch (error: any) { + expect(error.message).to.be.equal('Failed to upsert critter capture attachment data'); + } + }); + }); + + describe('upsertCritterMortalityAttachment', () => { + it('upserts attachment', async () => { + const mockResponse = { + rows: [{ critter_mortality_attachment_id: 1, key: 'key' }], + rowCount: 1 + } as any as Promise>; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + + const result = await service.upsertCritterMortalityAttachment({} as any); + + expect(mockConnection.sql).to.have.been.calledOnce; + expect(result).to.be.deep.equal({ critter_mortality_attachment_id: 1, key: 'key' }); + }); + + it('throws error when no rows are returned', async () => { + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + + try { + await service.upsertCritterMortalityAttachment({} as any); + } catch (error: any) { + expect(error.message).to.be.equal('Failed to upsert critter mortality attachment data'); + } + }); + }); + + describe('findAllCritterCaptureAttachments', () => { + it('finds all attachments', async () => { + const mockResponse = { rows: [{ key: 'key' }], rowCount: 1 } as any as Promise>; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + const result = await service.findAllCritterCaptureAttachments(1, 'uuid'); + + expect(mockConnection.sql).to.have.been.calledOnce; + expect(result).to.deep.equal([{ key: 'key' }]); + }); + }); + + describe('findCaptureAttachmentsByCritterId', () => { + it('finds all attachments by critter ID', async () => { + const mockResponse = { rows: [{ key: 'key' }], rowCount: 1 } as any as Promise>; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + const result = await service.findCaptureAttachmentsByCritterId(1); + + expect(mockConnection.sql).to.have.been.calledOnce; + expect(result).to.deep.equal([{ key: 'key' }]); + }); + }); + + describe('deleteCritterCaptureAttachments', () => { + it('deletes attachment', async () => { + const mockResponse = { rows: [{ key: 1 }, { key: 2 }], rowCount: 1 } as any as Promise>; + const mockConnection = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + + const result = await service.deleteCritterCaptureAttachments(1, [1, 2]); + + expect(mockConnection.knex).to.have.been.calledOnce; + expect(result).to.deep.equal([1, 2]); + }); + }); +}); diff --git a/api/src/repositories/critter-attachment-repository.ts b/api/src/repositories/critter-attachment-repository.ts new file mode 100644 index 0000000000..a977a44bc5 --- /dev/null +++ b/api/src/repositories/critter-attachment-repository.ts @@ -0,0 +1,246 @@ +import SQL from 'sql-template-strings'; +import { z } from 'zod'; +import { ATTACHMENT_TYPE } from '../constants/attachments'; +import { + CritterCaptureAttachmentModel, + CritterCaptureAttachmentRecord +} from '../database-models/critter_capture_attachment'; +import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { BaseRepository } from './base-repository'; +import { + CritterCaptureAttachmentPayload, + CritterMortalityAttachmentPayload +} from './critter-attachment-repository.interface'; + +/** + * A repository class for accessing Critter attachment data. + * + * @export + * @class CritterAttachmentRepository + * @extends {BaseRepository} + */ +export class CritterAttachmentRepository extends BaseRepository { + /** + * Get Critter Capture Attachment S3 key. + * + * Note: Joining on survey_id for security purposes. + * + * @param {number} surveyId - Survey ID + * @param {number} attachmentId - Critter Capture Attachment ID + * @return {*} {Promise} + */ + async getCritterCaptureAttachmentS3Key(surveyId: number, attachmentId: number): Promise { + const sqlStatement = SQL` + SELECT + key + FROM critter_capture_attachment cc + JOIN critter c + ON c.critter_id = cc.critter_id + JOIN survey s + ON s.survey_id = c.survey_id + WHERE cc.critter_capture_attachment_id = ${attachmentId} + AND s.survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sqlStatement, z.object({ key: z.string() })); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to get critter capture attachment signed URL', [ + 'AttachmentRepository->getCritterCaptureAttachmentS3Key', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0].key; + } + /** + * Upsert Critter Capture Attachment record. + * + * @return {*} {Promise<{ critter_capture_attachment_id: number; key: string }>} + * @memberof AttachmentRepository + */ + async upsertCritterCaptureAttachment( + payload: CritterCaptureAttachmentPayload + ): Promise<{ critter_capture_attachment_id: number; key: string }> { + const sqlStatement = SQL` + INSERT INTO critter_capture_attachment ( + critter_id, + critterbase_capture_id, + file_name, + file_size, + file_type, + key + ) + VALUES ( + ${payload.critter_id}, + ${payload.critterbase_capture_id}, + ${payload.file_name}, + ${payload.file_size}, + ${ATTACHMENT_TYPE.OTHER}, + ${payload.key} + ) + ON CONFLICT (critter_id, critterbase_capture_id, file_name) + DO UPDATE SET + file_name = ${payload.file_name}, + file_size = ${payload.file_size} + RETURNING + critter_capture_attachment_id, + key; + `; + + const response = await this.connection.sql( + sqlStatement, + z.object({ critter_capture_attachment_id: z.number(), key: z.string() }) + ); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to upsert critter capture attachment data', [ + 'AttachmentRepository->upsertCritterCaptureAttachment', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0]; + } + + /** + * Insert Critter Mortality Attachment record. + * + * @return {*} {Promise<{ critter_mortality_attachment_id: number; key: string }>} + * @memberof AttachmentRepository + */ + async upsertCritterMortalityAttachment( + payload: CritterMortalityAttachmentPayload + ): Promise<{ critter_mortality_attachment_id: number; key: string }> { + const sqlStatement = SQL` + INSERT INTO critter_capture_attachment ( + critter_id, + critterbase_capture_id, + file_name, + file_size, + file_type, + key + ) + VALUES ( + ${payload.critter_id}, + ${payload.critterbase_mortality_id}, + ${payload.file_name}, + ${payload.file_size}, + ${ATTACHMENT_TYPE.OTHER}, + ${payload.key} + ) + ON CONFLICT (critter_id, critterbase_mortality_id, file_name) + DO UPDATE SET + file_name = ${payload.file_name}, + file_size = ${payload.file_size} + RETURNING + critter_mortality_attachment_id, + key; + `; + + const response = await this.connection.sql( + sqlStatement, + z.object({ critter_mortality_attachment_id: z.number(), key: z.string() }) + ); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to upsert critter mortality attachment data', [ + 'AttachmentRepository->insertCritterMortalityAttachment', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0]; + } + + /** + * Find all Attachments for a Critterbase Capture ID. + * + * @param {number} surveyId - Survey ID + * @param {string} critterbaseCaptureId - Critterbase Capture ID + * @return {*} {Promise} + */ + async findAllCritterCaptureAttachments( + surveyId: number, + critterbaseCaptureId: string + ): Promise { + const sqlStatement = SQL` + SELECT cc.* + FROM critter_capture_attachment cc + INNER JOIN critter c + ON c.critter_id = cc.critter_id + INNER JOIN survey s + ON s.survey_id = c.survey_id + WHERE cc.critterbase_capture_id = ${critterbaseCaptureId} + AND s.survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sqlStatement, CritterCaptureAttachmentModel); + + return response.rows; + } + + /** + * Find Critter Capture Attachments by Critter ID. + * + * @param {number} critterId - SIMS Critter ID + * @return {*} {Promise} + * @memberof CritterAttachmentRepository + */ + async findCaptureAttachmentsByCritterId(critterId: number): Promise { + const sqlStatement = SQL` + SELECT + critter_capture_attachment_id, + uuid, + critter_id, + critterbase_capture_id, + file_type, + file_name, + file_size, + title, + description, + key + FROM critter_capture_attachment + WHERE critter_id = ${critterId}; + `; + + const response = await this.connection.sql(sqlStatement, CritterCaptureAttachmentRecord); + + return response.rows; + } + + /** + * Delete Critter Capture Attachments by ID. + * + * Note: Joining on survey_id for security purposes. + * + * @param {number} surveyId - Survey ID + * @param {number[]} deleteIds - Critter Capture Attachment ID's + * @return {*} {Promise} List of S3 keys that were deleted + */ + async deleteCritterCaptureAttachments(surveyId: number, deleteIds: number[]): Promise { + const knex = getKnex(); + + const queryBuilder = knex + .queryBuilder() + .del() + .from('critter_capture_attachment as cc') + .join('critter as c', 'c.critter_id', 'cc.critter_id') + .join('survey as s', 's.survey_id', 'c.survey_id') + .whereIn('cc.critter_capture_attachment_id', deleteIds) + .andWhere('s.survey_id', surveyId) + .returning('cc.key'); + + const response = await this.connection.knex(queryBuilder, z.object({ key: z.string() })); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to delete critter capture attachments', [ + 'AttachmentRepository->deleteCritterCaptureAttachments', + 'response was null or undefined, expected response != null' + ]); + } + + return response.rows.map((row) => row.key); + } +} diff --git a/api/src/services/attachment-service.ts b/api/src/services/attachment-service.ts index 017ea1c44e..ff5017d2e8 100644 --- a/api/src/services/attachment-service.ts +++ b/api/src/services/attachment-service.ts @@ -27,11 +27,11 @@ export interface IAttachmentType { } /** - * A repository class for accessing project and survey attachment data. + * A service class for accessing project and survey attachment data. * * @export - * @class AttachmentRepository - * @extends {BaseRepository} + * @class AttachmentService + * @extends {DBService} */ export class AttachmentService extends DBService { attachmentRepository: AttachmentRepository; diff --git a/api/src/services/critter-attachment-service.test.ts b/api/src/services/critter-attachment-service.test.ts new file mode 100644 index 0000000000..880b8f11e3 --- /dev/null +++ b/api/src/services/critter-attachment-service.test.ts @@ -0,0 +1,104 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { getMockDBConnection } from '../__mocks__/db'; +import { CritterAttachmentService } from './critter-attachment-service'; + +describe('CritterCaptureAttachmentService', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getCritterCaptureAttachmentS3Key', () => { + it('should call the repository method with correct params', async () => { + const connection = getMockDBConnection(); + const service = new CritterAttachmentService(connection); + + const mockRepoMethod = sinon + .stub(service.attachmentRepository, 'getCritterCaptureAttachmentS3Key') + .resolves('key'); + + const result = await service.getCritterCaptureAttachmentS3Key(1, 2); + + expect(mockRepoMethod.calledOnceWithExactly(1, 2)).to.be.true; + expect(result).to.equal('key'); + }); + }); + + describe('upsertCritterCaptureAttachment', () => { + it('should call the repository method with correct params', async () => { + const connection = getMockDBConnection(); + const service = new CritterAttachmentService(connection); + + const mockRepoMethod = sinon + .stub(service.attachmentRepository, 'upsertCritterCaptureAttachment') + .resolves({ critter_capture_attachment_id: 1, key: 'KEY' }) + .resolves({ critter_capture_attachment_id: 1, key: 'KEY' }); + + const result = await service.upsertCritterCaptureAttachment({ + critter_id: 1, + critterbase_capture_id: '123e4567-e89b-12d3-a456-426614174000', + file_name: 'test.txt', + file_size: 1024, + key: 'KEY' + }); + + expect(mockRepoMethod).to.have.been.calledOnceWithExactly({ + critter_id: 1, + critterbase_capture_id: '123e4567-e89b-12d3-a456-426614174000', + file_name: 'test.txt', + file_size: 1024, + key: 'KEY' + }); + + expect(result).to.deep.equal({ critter_capture_attachment_id: 1, key: 'KEY' }); + }); + }); + + describe('deleteCritterCaptureAttachments', () => { + it('should call the repository method with correct params', async () => { + const connection = getMockDBConnection(); + const service = new CritterAttachmentService(connection); + + const mockRepoMethod = sinon + .stub(service.attachmentRepository, 'deleteCritterCaptureAttachments') + .resolves(['key']); + + const result = await service.deleteCritterCaptureAttachments(1, [1, 2]); + + expect(mockRepoMethod).to.have.been.calledOnceWithExactly(1, [1, 2]); + expect(result).to.deep.equal(['key']); + }); + }); + + describe('findAllCritterCaptureAttachments', () => { + it('should call the repository method with correct params', async () => { + const connection = getMockDBConnection(); + const service = new CritterAttachmentService(connection); + + const mockRepoMethod = sinon + .stub(service.attachmentRepository, 'findAllCritterCaptureAttachments') + .resolves([{ critter_capture_attachment_id: 1, key: 'key' }] as any[]); + + const result = await service.findAllCritterCaptureAttachments(1, '123e4567-e89b-12d3-a456-426614174000'); + + expect(mockRepoMethod).to.have.been.calledOnceWithExactly(1, '123e4567-e89b-12d3-a456-426614174000'); + expect(result).to.deep.equal([{ critter_capture_attachment_id: 1, key: 'key' }]); + }); + }); + + describe('findAllCritterAttachments', () => { + it('should call the repository method with correct params', async () => { + const connection = getMockDBConnection(); + const service = new CritterAttachmentService(connection); + + const mockRepoMethod = sinon + .stub(service.attachmentRepository, 'findCaptureAttachmentsByCritterId') + .resolves([{ critter_attachment_id: 1, key: 'key' }] as any[]); + + const result = await service.findAllCritterAttachments(1); + + expect(mockRepoMethod).to.have.been.calledOnceWithExactly(1); + expect(result).to.deep.equal({ captureAttachments: [{ critter_attachment_id: 1, key: 'key' }] }); + }); + }); +}); diff --git a/api/src/services/critter-attachment-service.ts b/api/src/services/critter-attachment-service.ts new file mode 100644 index 0000000000..a347794a1d --- /dev/null +++ b/api/src/services/critter-attachment-service.ts @@ -0,0 +1,106 @@ +import { + CritterCaptureAttachmentModel, + CritterCaptureAttachmentRecord +} from '../database-models/critter_capture_attachment'; +import { IDBConnection } from '../database/db'; +import { CritterAttachmentRepository } from '../repositories/critter-attachment-repository'; +import { + CritterCaptureAttachmentPayload, + CritterMortalityAttachmentPayload +} from '../repositories/critter-attachment-repository.interface'; +import { DBService } from './db-service'; + +/** + * Attachment service for accessing Critter Attachments. + * + * @export + * @class AttachmentService + * @extends {DBService} + */ +export class CritterAttachmentService extends DBService { + attachmentRepository: CritterAttachmentRepository; + + constructor(connection: IDBConnection) { + super(connection); + + this.attachmentRepository = new CritterAttachmentRepository(connection); + } + + /** + * Get Critter Capture Attachment S3 key. + * + * @param {number} surveyId - Survey ID + * @param {number} attachmentId - Critter Capture Attachment ID + * @return {*} {Promise} + */ + async getCritterCaptureAttachmentS3Key(surveyId: number, attachmentId: number): Promise { + return this.attachmentRepository.getCritterCaptureAttachmentS3Key(surveyId, attachmentId); + } + + /** + * Upsert Critter Capture Attachment. + * + * @param {CritterCaptureAttachmentPayload} payload + * @return {*} {Promise<{critter_capture_attachment_id: number; key: string}>} + */ + async upsertCritterCaptureAttachment( + payload: CritterCaptureAttachmentPayload + ): Promise<{ critter_capture_attachment_id: number; key: string }> { + return this.attachmentRepository.upsertCritterCaptureAttachment(payload); + } + + /** + * Delete Critter Capture Attachments. + * + * @param {number} surveyId - Survey ID + * @param {number[]} deleteIds - Critter Capture Attachment ID's + * @return {*} {Promise} List of keys that were deleted + * + */ + async deleteCritterCaptureAttachments(surveyId: number, deleteIds: number[]): Promise { + return this.attachmentRepository.deleteCritterCaptureAttachments(surveyId, deleteIds); + } + + /** + * Upsert Critter Mortality Attachment. + * + * @param {CritterMortalityAttachmentPayload} payload + * @return {*} {Promise<{critter_mortality_attachment_id: number; key: string}>} + */ + async upsertCritterMortalityAttachment( + payload: CritterMortalityAttachmentPayload + ): Promise<{ critter_mortality_attachment_id: number; key: string }> { + return this.attachmentRepository.upsertCritterMortalityAttachment(payload); + } + + /** + * Find all Attachments for a Critterbase Capture ID. + * + * @param {number} surveyId - Survey ID + * @param {string} critterbaseCaptureId - Critterbase Capture ID + * @return {*} {Promise} + */ + async findAllCritterCaptureAttachments( + surveyId: number, + critterbaseCaptureId: string + ): Promise { + return this.attachmentRepository.findAllCritterCaptureAttachments(surveyId, critterbaseCaptureId); + } + + /** + * Find all Attachments for a Critterbase Critter ID. + * + * TODO: Include mortality attachments. + * + * @param {number} critterId - SIMS Critter ID + * @return {*} {Promise<{captureAttachments: CritterCaptureAttachmentRecord[]}>} + */ + async findAllCritterAttachments( + critterId: number + ): Promise<{ captureAttachments: CritterCaptureAttachmentRecord[] }> { + const [captureAttachments] = await Promise.all([ + this.attachmentRepository.findCaptureAttachmentsByCritterId(critterId) + ]); + return { captureAttachments }; + } +} diff --git a/api/src/utils/file-utils.test.ts b/api/src/utils/file-utils.test.ts index 6137d956db..028f2ec0ed 100644 --- a/api/src/utils/file-utils.test.ts +++ b/api/src/utils/file-utils.test.ts @@ -2,6 +2,7 @@ import { S3Client } from '@aws-sdk/client-s3'; import { expect } from 'chai'; import { describe } from 'mocha'; import { + bulkDeleteFilesFromS3, deleteFileFromS3, generateS3FileKey, getS3HostUrl, @@ -21,6 +22,14 @@ describe('deleteFileFromS3', () => { }); }); +describe('bulkDeleteFilesFromS3', () => { + it('returns null when no keys provided', async () => { + const result = await bulkDeleteFilesFromS3([]); + + expect(result).to.be.null; + }); +}); + describe('getS3SignedURL', () => { it('returns null when no key specified', async () => { const result = await getS3SignedURL(null as unknown as string); @@ -85,6 +94,36 @@ describe('generateS3FileKey', () => { expect(result).to.equal('some/s3/prefix/projects/1/surveys/2/submissions/3/testFileName'); }); + + it('returns critter captures folder file path', async () => { + process.env.S3_KEY_PREFIX = 'some/s3/prefix'; + + const result = generateS3FileKey({ + projectId: 1, + surveyId: 2, + critterId: 3, + folder: 'captures', + critterbaseCaptureId: '123-456-789', + fileName: 'testFileName' + }); + + expect(result).to.equal('some/s3/prefix/projects/1/surveys/2/critters/3/captures/123-456-789/testFileName'); + }); + + it('returns critter mortalities folder file path', async () => { + process.env.S3_KEY_PREFIX = 'some/s3/prefix'; + + const result = generateS3FileKey({ + projectId: 1, + surveyId: 2, + critterId: 3, + folder: 'mortalities', + critterbaseMortalityId: '123-456-789', + fileName: 'testFileName' + }); + + expect(result).to.equal('some/s3/prefix/projects/1/surveys/2/critters/3/mortalities/123-456-789/testFileName'); + }); }); describe('getS3HostUrl', () => { diff --git a/api/src/utils/file-utils.ts b/api/src/utils/file-utils.ts index da556e1e49..517260898d 100644 --- a/api/src/utils/file-utils.ts +++ b/api/src/utils/file-utils.ts @@ -2,6 +2,8 @@ import { CompleteMultipartUploadCommandOutput, DeleteObjectCommand, DeleteObjectCommandOutput, + DeleteObjectsCommand, + DeleteObjectsCommandOutput, GetObjectCommand, GetObjectCommandOutput, HeadObjectCommand, @@ -121,6 +123,33 @@ export async function deleteFileFromS3(key: string): Promise} the response from S3 or null if required parameters are null + */ +export async function bulkDeleteFilesFromS3(keys: string[]): Promise { + const s3Client = _getS3Client(); + + if (!keys.length || !s3Client) { + return null; + } + + return s3Client.send( + new DeleteObjectsCommand({ + Bucket: _getObjectStoreBucketName(), + Delete: { + Objects: keys.map((key) => ({ Key: key })) + } + }) + ); +} + /** * Upload a file to S3. * @@ -295,7 +324,24 @@ export async function getS3SignedURLs(keys: string[]): Promise<(string | null)[] return Promise.all(keys.map((key) => getS3SignedURL(key))); } -export interface IS3FileKey { +type Projects3Key = { + /** + * The project ID the file is associated with. + */ + projectId: number; + /** + * The sub-folder where the file is stored. + * + * Note: For regular/generic file attachments, leave this undefined. + */ + folder?: 'reports' | 'telemetry-credentials'; + /** + * The name of the file. + */ + fileName: string; +}; + +type SurveyS3Key = { /** * The project ID the file is associated with. */ @@ -303,7 +349,7 @@ export interface IS3FileKey { /** * The survey ID the file is associated with. */ - surveyId?: number; + surveyId: number; /** * The template submission ID the file is associated with. * @@ -311,19 +357,72 @@ export interface IS3FileKey { */ submissionId?: number; /** - * The sub-folder in the project/survey where the file is stored. + * The sub-folder where the file is stored. * * Note: For regular/generic file attachments, leave this undefined. */ folder?: 'reports' | 'telemetry-credentials'; /** * The name of the file. - * - * @type {string} - * @memberof IS3FileKey */ fileName: string; -} +}; + +type CritterCaptureS3Key = { + /** + * The project ID the file is associated with. + */ + projectId: number; + /** + * The survey ID the file is associated with. + */ + surveyId: number; + /** + * The SIMS Critter ID the file is associated with. + */ + critterId: number; + /** + * The sub-folder where the file is stored. + */ + folder: 'captures'; + /** + * The Critterbase Capture ID (uuid) the file is associated with. + */ + critterbaseCaptureId: string; + /** + * The name of the file. + */ + fileName: string; +}; + +type CritterMortalityS3Key = { + /** + * The project ID the file is associated with. + */ + projectId: number; + /** + * The survey ID the file is associated with. + */ + surveyId: number; + /** + * The SIMS Critter ID the file is associated with. + */ + critterId: number; + /** + * The sub-folder where the file is stored. + */ + folder: 'mortalities'; + /** + * The Critterbase Mortality ID (uuid) the file is associated with. + */ + critterbaseMortalityId: string; + /** + * The name of the file. + */ + fileName: string; +}; + +export type IS3FileKey = Projects3Key | SurveyS3Key | CritterCaptureS3Key | CritterMortalityS3Key; /** * Generate an S3 key for a project or survey attachment file. @@ -340,20 +439,33 @@ export function generateS3FileKey(options: IS3FileKey): string { keyParts.push(options.projectId); } - if (options.surveyId) { + if ('surveyId' in options && options.surveyId) { keyParts.push('surveys'); keyParts.push(options.surveyId); } - if (options.submissionId) { + if ('submissionId' in options && options.submissionId) { keyParts.push('submissions'); keyParts.push(options.submissionId); } - if (options.folder) { + if ('critterId' in options && options.critterId) { + keyParts.push('critters'); + keyParts.push(options.critterId); + } + + if ('folder' in options && options.folder) { keyParts.push(options.folder); } + if ('critterbaseCaptureId' in options && options.critterbaseCaptureId) { + keyParts.push(options.critterbaseCaptureId); + } + + if ('critterbaseMortalityId' in options && options.critterbaseMortalityId) { + keyParts.push(options.critterbaseMortalityId); + } + if (options.fileName) { keyParts.push(options.fileName); } diff --git a/api/src/utils/logger.ts b/api/src/utils/logger.ts index d84219e57e..5b7c40e2d7 100644 --- a/api/src/utils/logger.ts +++ b/api/src/utils/logger.ts @@ -9,7 +9,9 @@ import DailyRotateFile from 'winston-daily-rotate-file'; const getLoggerTransportTypes = (): string[] => { const transportTypes = []; - if (process.env.npm_lifecycle_event !== 'test') { + // Do not output logs to file when running unit tests + // Note: Both lifecycle events are needed to prevent log files ie: `npm run test` or `npm run test-watch` + if (process.env.npm_lifecycle_event !== 'test' && process.env.npm_lifecycle_event !== 'test-watch') { transportTypes.push('file'); } diff --git a/app/src/components/attachments/AttachmentTableDropzone.tsx b/app/src/components/attachments/AttachmentTableDropzone.tsx new file mode 100644 index 0000000000..4a4b67782e --- /dev/null +++ b/app/src/components/attachments/AttachmentTableDropzone.tsx @@ -0,0 +1,148 @@ +import { GridColDef } from '@mui/x-data-grid'; +import AttachmentsListItemMenuButton from 'components/attachments/list/AttachmentsListItemMenuButton'; +import { GenericFileNameColDef, GenericFileSizeColDef } from 'components/data-grid/GenericGridColumnDefinitions'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import FileUpload from 'components/file-upload/FileUpload'; +import { UploadFileStatus } from 'components/file-upload/FileUploadItem'; +import { PublishStatus } from 'constants/attachments'; + +interface IAttachment { + /** + * Attachment ID. + * + * @type {number} + */ + id: number; + /** + * S3 key for the attachment. + * + * @type {string} + */ + s3Key: string; + /** + * Attachment file name. + * + * @type {string} + */ + name: string; + /** + * Attachment file size in bytes. + * + * @type {number} + */ + size: number; + /** + * Attachment file type. + * + * @type {string} + * @example 'Other', 'Report', 'Capture', 'Mortality', 'Cfg' + */ + type: string; +} + +interface IAttachmentTableDropzoneProps { + /** + * List of uploaded attachments to display in the table. + * + * @type {IAttachment[] | undefined} + */ + attachments?: IAttachment[]; + /** + * Callback to download an attachment. + * + * @param {(id: number, attachmentType: string) => void} + */ + onDownloadAttachment: (id: number, attachmentType: string) => void; + /** + * Callback when a file is staged for upload. + * + * @param {(file: File | null) => void} + */ + onStagedAttachment: (file: File | null) => void; + /** + * Callback when a staged attachment is removed. + * + * @param {(fileName: string) => void} + */ + onRemoveStagedAttachment: (fileName: string) => void; + /** + * Callback when a previously uploaded attachment is removed. + * + * Note: Previously uploaded attachments exist in S3 and referenced in the database. + * + * @param {(id: number) => void} + */ + onRemoveUploadedAttachment: (id: number) => void; +} + +/** + * AttachmentTableDropzone + * + * @description Renders a dropzone for staged attachements and a table of uploaded attachments. + * + * @param {IAnimalAttachmentsProps} props + * @returns {*} + */ +export const AttachmentTableDropzone = (props: IAttachmentTableDropzoneProps): JSX.Element => { + const attachmentsListColumnDefs: GridColDef[] = [ + GenericFileNameColDef({ + field: 'name', + headerName: 'Name', + onClick: (params) => props.onDownloadAttachment(params.row.id, params.row.type) + }), + { + field: 'type', + flex: 1, + headerName: 'Type', + disableColumnMenu: true + }, + GenericFileSizeColDef({ field: 'size', headerName: 'Size' }), + { + field: 'actions', + headerName: '', + type: 'actions', + width: 70, + sortable: false, + disableColumnMenu: true, + resizable: false, + renderCell: (params) => { + return ( + props.onDownloadAttachment(params.row.id, params.row.type)} + onDeleteFile={() => props.onRemoveUploadedAttachment(params.row.id)} + onViewDetails={() => undefined} + /> + ); + } + } + ]; + return ( + <> + + + {props.attachments && props.attachments.length > 0 && ( + + noRowsMessage={'No Uploaded Attachments'} + columns={attachmentsListColumnDefs} + rows={props.attachments ?? []} + pageSizeOptions={[5, 10, 20]} + rowCount={props.attachments.length} + disableRowSelectionOnClick + initialState={{ + pagination: { + paginationModel: { + pageSize: 5 + } + } + }} + /> + )} + + ); +}; diff --git a/app/src/components/data-grid/GenericGridColumnDefinitions.tsx b/app/src/components/data-grid/GenericGridColumnDefinitions.tsx index f432551087..2bf3a109d6 100644 --- a/app/src/components/data-grid/GenericGridColumnDefinitions.tsx +++ b/app/src/components/data-grid/GenericGridColumnDefinitions.tsx @@ -1,11 +1,15 @@ +import { mdiFileOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Link } from '@mui/material'; import Typography from '@mui/material/Typography'; +import { Stack } from '@mui/system'; import { GridCellParams, GridColDef, GridValidRowModel } from '@mui/x-data-grid'; import TextFieldDataGrid from 'components/data-grid/TextFieldDataGrid'; import TimePickerDataGrid from 'components/data-grid/TimePickerDataGrid'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { default as dayjs } from 'dayjs'; import { round } from 'lodash-es'; -import { getFormattedDate } from 'utils/Utils'; +import { getFormattedDate, getFormattedFileSize } from 'utils/Utils'; export const GenericDateColDef = (props: { field: string; @@ -243,3 +247,52 @@ export const GenericLongitudeColDef = (props: { } }; }; + +export const GenericFileNameColDef = (props: { + field: string; + headerName: string; + onClick?: (params: GridCellParams) => void; +}): GridColDef => { + return { + field: props.field, + headerName: props.headerName, + flex: 1, + disableColumnMenu: true, + renderCell: (params) => { + return ( + + + props.onClick?.(params)} tabIndex={0}> + {params.value} + + + ); + } + }; +}; + +export const GenericFileSizeColDef = (props: { + field: string; + headerName: string; +}): GridColDef => { + return { + field: props.field, + headerName: props.headerName, + flex: 1, + disableColumnMenu: true, + valueGetter: (params) => { + return getFormattedFileSize(params.value); + } + }; +}; diff --git a/app/src/components/file-upload/FileUpload.tsx b/app/src/components/file-upload/FileUpload.tsx index 50d5507747..eca639a33c 100644 --- a/app/src/components/file-upload/FileUpload.tsx +++ b/app/src/components/file-upload/FileUpload.tsx @@ -19,10 +19,6 @@ export interface IUploadFile { error?: string; } -export interface IUploadFileListProps { - files: IUploadFile[]; -} - export type IReplaceHandler = () => void; export interface IFileUploadProps { diff --git a/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx b/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx index 5e9f2d0c0a..744b7956db 100644 --- a/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx +++ b/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx @@ -8,7 +8,9 @@ import Typography from '@mui/material/Typography'; import { SkeletonHorizontalStack } from 'components/loading/SkeletonLoaders'; import { AnimalCaptureCardContainer } from 'features/surveys/animals/profile/captures/components/AnimalCaptureCardContainer'; import { AnimalCapturesToolbar } from 'features/surveys/animals/profile/captures/components/AnimalCapturesToolbar'; -import { useAnimalPageContext, useSurveyContext } from 'hooks/useContext'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useAnimalPageContext, useDialogContext, useSurveyContext } from 'hooks/useContext'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import { ICaptureResponse, @@ -31,6 +33,8 @@ export interface ICaptureWithSupplementaryData extends ICaptureResponse { */ export const AnimalCaptureContainer = () => { const critterbaseApi = useCritterbaseApi(); + const biohubApi = useBiohubApi(); + const dialogContext = useDialogContext(); const history = useHistory(); @@ -73,36 +77,73 @@ export const AnimalCaptureContainer = () => { })) || []; const handleDelete = async (selectedCapture: string, critter_id: number) => { - // Delete markings and measurements associated with the capture to avoid foreign key constraint error - await critterbaseApi.critters.bulkUpdate({ - markings: data?.markings - .filter((marking) => marking.capture_id === selectedCapture) - .map((marking) => ({ - ...marking, - critter_id: selectedAnimal.critterbase_critter_id, - _delete: true - })), - qualitative_measurements: - data?.measurements.qualitative - .filter((measurement) => measurement.capture_id === selectedCapture) - .map((measurement) => ({ - ...measurement, + try { + // Delete markings and measurements associated with the capture to avoid foreign key constraint error + await critterbaseApi.critters.bulkUpdate({ + markings: data?.markings + .filter((marking) => marking.capture_id === selectedCapture) + .map((marking) => ({ + ...marking, + critter_id: selectedAnimal.critterbase_critter_id, _delete: true - })) ?? [], - quantitative_measurements: - data?.measurements.quantitative - .filter((measurement) => measurement.capture_id === selectedCapture) - .map((measurement) => ({ - ...measurement, - _delete: true - })) ?? [] - }); + })), + qualitative_measurements: + data?.measurements.qualitative + .filter((measurement) => measurement.capture_id === selectedCapture) + .map((measurement) => ({ + ...measurement, + _delete: true + })) ?? [], + quantitative_measurements: + data?.measurements.quantitative + .filter((measurement) => measurement.capture_id === selectedCapture) + .map((measurement) => ({ + ...measurement, + _delete: true + })) ?? [] + }); + + // Delete the actual capture + await critterbaseApi.capture.deleteCapture(selectedCapture); + + // Delete all capture attachments + await biohubApi.animal.deleteCaptureAttachments({ + projectId, + surveyId, + critterId: selectedAnimal.critter_id, + critterbaseCaptureId: selectedCapture + }); - // Delete the actual capture - await critterbaseApi.capture.deleteCapture(selectedCapture); + // Refresh capture container + animalPageContext.critterDataLoader.refresh(projectId, surveyId, critter_id); + + // Show success snackbar + dialogContext.setSnackbar({ + open: true, + onClose: () => dialogContext.setSnackbar({ open: false }), + snackbarMessage: ( + + Successfully deleted Capture + + ) + }); + } catch (error) { + const apiError = error as APIError; - // Refresh capture container - animalPageContext.critterDataLoader.refresh(projectId, surveyId, critter_id); + dialogContext.setErrorDialog({ + open: true, + dialogTitle: 'Error deleting Capture', + dialogText: 'An error occurred while deleting the Capture.', + dialogError: apiError.message, + dialogErrorDetails: apiError.errors, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + } + }); + } }; const capturesWithLocation = captures.filter((capture) => capture.capture_location); 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 ea1baae3ce..580cbaf342 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 @@ -1,9 +1,18 @@ import { Divider } from '@mui/material'; import Stack from '@mui/material/Stack'; import FormikErrorSnackbar from 'components/alert/FormikErrorSnackbar'; +import { AttachmentTableDropzone } from 'components/attachments/AttachmentTableDropzone'; import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; import { Formik, FormikProps } from 'formik'; -import { ICreateCaptureRequest, IEditCaptureRequest } from 'interfaces/useCritterApi.interface'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import { useS3FileDownload } from 'hooks/useS3Download'; +import { + ICreateCaptureRequest, + ICritterCaptureAttachment, + IEditCaptureRequest +} from 'interfaces/useCritterApi.interface'; +import { useState } from 'react'; import { isDefined } from 'utils/Utils'; import yup from 'utils/YupSchema'; import { MarkingsForm } from '../../../markings/MarkingsForm'; @@ -12,10 +21,13 @@ import { CaptureGeneralInformationForm } from './general-information/CaptureGene import { CaptureLocationForm } from './location/CaptureLocationForm'; import { ReleaseLocationForm } from './location/ReleaseLocationForm'; +const CRITTER_CAPTURE_ATTACHMENT_TYPE = 'Capture'; + export interface IAnimalCaptureFormProps { initialCaptureData: FormikValuesType; handleSubmit: (formikData: FormikValuesType) => void; formikRef: React.RefObject>; + captureAttachments?: ICritterCaptureAttachment[]; } /** @@ -28,9 +40,23 @@ export interface IAnimalCaptureFormProps( props: IAnimalCaptureFormProps ) => { + const biohubApi = useBiohubApi(); + const { projectId, surveyId } = useSurveyContext(); + + const { downloadS3File } = useS3FileDownload(); + + // Track the capture attachment ids that are flagged for deletion (formik ref is not stateful) + const [captureDeleteIds, setCaptureDeleteIds] = useState([]); + const animalCaptureYupSchema = yup.object({ + attachments: yup.object({ + capture_attachments: yup.object({ + create: yup.mixed(), + delete: yup.array().of(yup.number()) + }) + }), capture: yup.object({ - capture_id: yup.string().nullable(), + capture_id: yup.string(), capture_date: yup.string().required('Capture date is required'), capture_time: yup.string().nullable(), capture_comment: yup.string().required('Capture comment is required'), @@ -136,6 +162,47 @@ export const AnimalCaptureForm = { + if (!file) return; + + props.formikRef.current?.setFieldValue(`attachments.capture_attachments.create['${file.name}']`, file); + }; + + /** + * Remove a staged file. (Create) + + * @param {string} fileName + * @return {void} + */ + const removeStagedFile = (fileName: string) => { + props.formikRef.current?.setFieldValue(`attachments.capture_attachments.create['${fileName}']`, undefined); + }; + + /** + * Flag uploaded file for delete. (Edit) + * + * @param {number} attachmentId + * @return {void} + */ + const flagUploadedFileForDelete = (attachmentId: number) => { + // If the attachment is not already flagged for deletion, add it to the list + if (!captureDeleteIds.includes(attachmentId)) { + const newDeleteIds = [...captureDeleteIds, attachmentId]; + + // Update the state of the deleted ids + setCaptureDeleteIds(newDeleteIds); + + // Update the formik field + props.formikRef.current?.setFieldValue(`attachments.capture_attachments.delete`, newDeleteIds); + } + }; + return ( } /> + ({ + id: attachment.critter_capture_attachment_id, + s3Key: attachment.key, + name: attachment.file_name ?? 'Unknown', + size: attachment.file_size, + type: attachment.file_type + })) + // Filter out attachments that are flagged for deletion + .filter((attachment) => !captureDeleteIds.includes(attachment.id))} + onStagedAttachment={addStagedFile} + onRemoveStagedAttachment={removeStagedFile} + onRemoveUploadedAttachment={flagUploadedFileForDelete} + onDownloadAttachment={(id) => + downloadS3File( + biohubApi.survey.getSurveyAttachmentSignedURL( + projectId, + surveyId, + id, + CRITTER_CAPTURE_ATTACHMENT_TYPE + ) + ) + } + /> + } + /> + { const history = useHistory(); + const biohubApi = useBiohubApi(); const critterbaseApi = useCritterbaseApi(); const surveyContext = useSurveyContext(); @@ -106,68 +114,69 @@ export const CreateCapturePage = () => { setIsSaving(true); try { - const surveyCritterId = animalPageContext.selectedAnimal?.critter_id; - const critterbaseCritterId = animalPageContext.selectedAnimal?.critterbase_critter_id; + const surveyCritterId = Number(animalPageContext.selectedAnimal?.critter_id); + const critterbaseCritterId = String(animalPageContext.selectedAnimal?.critterbase_critter_id); + const critterbaseCaptureId = v4(); // Generate a static UUID for the capture and attachments + const captureAttachments = Object.values(values.attachments.capture_attachments.create); if (!values || !critterbaseCritterId || values.capture.capture_location?.geometry.type !== 'Point') { return; } - const captureLocation = { - longitude: values.capture.capture_location.geometry.coordinates[0], - latitude: values.capture.capture_location.geometry.coordinates[1], - coordinate_uncertainty: 0, - coordinate_uncertainty_units: 'm' - }; - - // if release location is null, use the capture location, otherwise format it for critterbase - const releaseLocation = - values.capture.release_location?.geometry?.type === 'Point' - ? { - longitude: values.capture.release_location.geometry.coordinates[0], - latitude: values.capture.release_location.geometry.coordinates[1], - coordinate_uncertainty: 0, - coordinate_uncertainty_units: 'm' - } - : captureLocation; - - // Must create capture first to avoid foreign key constraints. Can't guarantee that the capture is - // inserted before the measurements/markings. - const captureResponse = await critterbaseApi.capture.createCapture({ - critter_id: critterbaseCritterId, - capture_id: undefined, - capture_date: values.capture.capture_date, - capture_time: values.capture.capture_time || undefined, - release_date: values.capture.release_date || values.capture.capture_date, - release_time: values.capture.release_time || values.capture.capture_time || undefined, - capture_comment: values.capture.capture_comment || undefined, - release_comment: values.capture.release_comment || undefined, - capture_location: captureLocation, - release_location: releaseLocation ?? captureLocation - }); + const captureLocations: ILocationCreate[] = [ + { + location_id: v4(), + longitude: values.capture.capture_location.geometry.coordinates[0], + latitude: values.capture.capture_location.geometry.coordinates[1], + coordinate_uncertainty: 0, + coordinate_uncertainty_unit: 'm' + } + ]; - if (!captureResponse) { - showCreateErrorDialog({ - dialogError: 'An error occurred while attempting to create the capture record.', - dialogErrorDetails: ['Capture create failed'] + if (values.capture.release_location?.geometry.type === 'Point') { + captureLocations.push({ + location_id: v4(), + longitude: values.capture.release_location.geometry.coordinates[0], + latitude: values.capture.release_location.geometry.coordinates[1], + coordinate_uncertainty: 0, + coordinate_uncertainty_unit: 'm' }); - return; } - // Create new measurements added while editing the capture + /** + * Create the Capture, Markings, and Measurements and Locations in Critterbase. + * + * Note: Critterbase will add the data in the correct order to prevent foreign key constraints. + */ const bulkResponse = await critterbaseApi.critters.bulkCreate({ + locations: captureLocations, + captures: [ + { + critter_id: critterbaseCritterId, + capture_id: critterbaseCaptureId, + capture_date: values.capture.capture_date, + capture_time: values.capture.capture_time || undefined, + release_date: values.capture.release_date || values.capture.capture_date, + release_time: values.capture.release_time || values.capture.capture_time || undefined, + capture_comment: values.capture.capture_comment || undefined, + release_comment: values.capture.release_comment || undefined, + capture_location_id: captureLocations[0].location_id, + release_location_id: + captureLocations.length > 1 ? captureLocations[1].location_id : captureLocations[0].location_id + } + ], markings: values.markings.map((marking) => ({ ...marking, marking_id: marking.marking_id, critter_id: critterbaseCritterId, - capture_id: captureResponse.capture_id + capture_id: critterbaseCaptureId })), qualitative_measurements: values.measurements .filter(isQualitativeMeasurementCreate) // Format qualitative measurements for create .map((measurement) => ({ critter_id: critterbaseCritterId, - capture_id: captureResponse.capture_id, + capture_id: critterbaseCaptureId, taxon_measurement_id: measurement.taxon_measurement_id, qualitative_option_id: measurement.qualitative_option_id })), @@ -176,7 +185,7 @@ export const CreateCapturePage = () => { // Format quantitative measurements for create .map((measurement) => ({ critter_id: critterbaseCritterId, - capture_id: captureResponse.capture_id, + capture_id: critterbaseCaptureId, taxon_measurement_id: measurement.taxon_measurement_id, value: measurement.value })) @@ -190,8 +199,25 @@ export const CreateCapturePage = () => { return; } - // Refresh page + // Upload Capture attachments + if (captureAttachments.length) { + await biohubApi.animal + .uploadCritterCaptureAttachments({ + projectId, + surveyId, + critterId: surveyCritterId, + critterbaseCaptureId: critterbaseCaptureId, + files: captureAttachments + }) + .catch(() => { + showCreateErrorDialog({ + dialogError: 'Failed to upload capture attachments', + dialogErrorDetails: ['Failed to upload capture attachments'] + }); + }); + } + // Refresh page if (surveyCritterId) { animalPageContext.critterDataLoader.refresh(projectId, surveyId, surveyCritterId); } @@ -200,7 +226,7 @@ export const CreateCapturePage = () => { } catch (error) { const apiError = error as APIError; showCreateErrorDialog({ - dialogTitle: 'Error Creating Survey', + dialogTitle: CreateCaptureI18N.createErrorTitle, dialogError: apiError?.message, dialogErrorDetails: apiError?.errors }); diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx index 688e0dcf9a..e62850a12f 100644 --- a/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx +++ b/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx @@ -13,6 +13,7 @@ import { EditCaptureI18N } from 'constants/i18n'; import { AnimalCaptureForm } from 'features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm'; import { FormikProps } from 'formik'; import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { useAnimalPageContext, useDialogContext, useProjectContext, useSurveyContext } from 'hooks/useContext'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import useDataLoader from 'hooks/useDataLoader'; @@ -32,6 +33,7 @@ export const EditCapturePage = () => { const history = useHistory(); const critterbaseApi = useCritterbaseApi(); + const biohubApi = useBiohubApi(); const surveyContext = useSurveyContext(); const projectContext = useProjectContext(); @@ -51,13 +53,25 @@ export const EditCapturePage = () => { const { projectId, surveyId } = surveyContext; + useEffect(() => { + if (!surveyCritterId) { + return; + } + + animalPageContext.critterDataLoader.load(projectId, surveyId, surveyCritterId); + }, [animalPageContext.critterDataLoader, projectId, surveyId, surveyCritterId]); + const critter = animalPageContext.critterDataLoader.data; - const captureDataLoader = useDataLoader(() => critterbaseApi.capture.getCapture(captureId)); + const captureDataLoader = useDataLoader((captureId: string) => critterbaseApi.capture.getCapture(captureId)); useEffect(() => { - captureDataLoader.load(); - }, [captureDataLoader]); + if (!captureId) { + return; + } + + captureDataLoader.load(captureId); + }, [captureDataLoader, captureId]); const capture = captureDataLoader.data; @@ -88,7 +102,11 @@ export const EditCapturePage = () => { setIsSaving(true); try { + const surveyCritterId = Number(animalPageContext.selectedAnimal?.critter_id); const critterbaseCritterId = animalPageContext.selectedAnimal?.critterbase_critter_id; + const critterbaseCaptureId = capture.capture_id; + const captureAttachments = Object.values(values.attachments.capture_attachments.create); + const captureAttachmentsToDelete = values.attachments.capture_attachments.delete; if (!values || !critterbaseCritterId || values.capture.capture_location?.geometry.type !== 'Point') { return; @@ -163,6 +181,32 @@ export const EditCapturePage = () => { return; } + // Upload Capture attachments and delete any marked for deletion + if (captureAttachments.length || captureAttachmentsToDelete.length) { + await biohubApi.animal + .uploadCritterCaptureAttachments({ + projectId, + surveyId, + critterId: surveyCritterId, + critterbaseCaptureId: critterbaseCaptureId, + files: captureAttachments, + deleteIds: captureAttachmentsToDelete + }) + .catch(() => { + dialogContext.setErrorDialog({ + dialogTitle: 'Failed to modify capture attachments.', + dialogText: EditCaptureI18N.createErrorText, + open: true, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + } + }); + }); + } + // Refresh page if (surveyCritterId) { animalPageContext.critterDataLoader.refresh(projectId, surveyId, surveyCritterId); @@ -191,6 +235,12 @@ export const EditCapturePage = () => { // Initial formik values const initialFormikValues: IEditCaptureRequest = { + attachments: { + capture_attachments: { + create: {}, + delete: [] + } + }, capture: { capture_id: capture.capture_id, capture_method_id: capture.capture_method_id ?? '', @@ -313,6 +363,9 @@ export const EditCapturePage = () => { initialCaptureData={initialFormikValues} handleSubmit={(formikData) => handleSubmit(formikData)} formikRef={formikRef} + captureAttachments={critter.attachments.capture_attachments.filter( + (attachment) => attachment.critterbase_capture_id === capture.capture_id + )} /> + to={`/admin/projects/${projectId}/surveys/${surveyId}/animals/${selectedAnimal.critter_id}/capture/${selectedCapture}/edit`}> diff --git a/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx b/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx index a5a8d1e6ad..a9add54457 100644 --- a/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx +++ b/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx @@ -76,7 +76,7 @@ export const AnimalMortalityCardContainer = (props: IAnimalMortalityCardContaine } }}> + to={`/admin/projects/${projectId}/surveys/${surveyId}/animals/${selectedAnimal.critter_id}/mortality/${selectedMortality}/edit`}> diff --git a/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx b/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx index 737235ba72..50216895d8 100644 --- a/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx +++ b/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx @@ -52,13 +52,26 @@ export const EditMortalityPage = () => { const { projectId, surveyId } = surveyContext; + useEffect(() => { + if (!surveyCritterId) { + return; + } + + animalPageContext.critterDataLoader.load(projectId, surveyId, surveyCritterId); + }, [animalPageContext.critterDataLoader, projectId, surveyCritterId, surveyId]); + const critter = animalPageContext.critterDataLoader.data; - const mortalityDataLoader = useDataLoader(() => critterbaseApi.mortality.getMortality(mortalityId)); + const mortalityDataLoader = useDataLoader((mortalityId: string) => + critterbaseApi.mortality.getMortality(mortalityId) + ); useEffect(() => { - mortalityDataLoader.load(); - }, [mortalityDataLoader]); + if (!mortalityId) { + return; + } + mortalityDataLoader.load(mortalityId); + }, [mortalityDataLoader, mortalityId]); const mortality = mortalityDataLoader.data; diff --git a/app/src/features/surveys/view/survey-animals/animal.ts b/app/src/features/surveys/view/survey-animals/animal.ts index fb1341dabb..38c3e75114 100644 --- a/app/src/features/surveys/view/survey-animals/animal.ts +++ b/app/src/features/surveys/view/survey-animals/animal.ts @@ -1,6 +1,7 @@ import { DATE_LIMIT } from 'constants/dateTimeFormats'; import { default as dayjs } from 'dayjs'; import { + ILocationCreate, IQualitativeMeasurementCreate, IQualitativeMeasurementUpdate, IQuantitativeMeasurementCreate, @@ -82,6 +83,19 @@ export const CreateCritterCaptureSchema = yup.object({ release_comment: yup.string().optional() }); +export const CreateBulkCritterCaptureSchema = yup.object({ + capture_id: yup.string().optional(), + critter_id: yup.string().required('Required'), + capture_location_id: yup.string().uuid().optional(), + release_location_id: yup.string().uuid().optional(), + capture_comment: yup.string().optional(), + capture_date: yup.string().required('Required'), + capture_time: yup.string().optional().nullable(), + release_date: yup.string().optional().nullable(), + release_time: yup.string().optional().nullable(), + release_comment: yup.string().optional() +}); + export const CreateCritterSchema = yup.object({ critter_id: yup.string().optional(), itis_tsn: yup.number().required('Required'), @@ -152,6 +166,7 @@ export type ICreateCritterMarking = yup.InferType; export type ICreateCritterCollectionUnit = yup.InferType & { key?: string }; export type ICreateCritterCapture = yup.InferType; +export type ICreateBulkCritterCapture = yup.InferType; export type ICreateCritterFamily = yup.InferType; export type ICreateCritterMortality = yup.InferType; @@ -162,7 +177,8 @@ export type IBulkCreate = { critters?: ICreateCritter[]; qualitative_measurements?: IQualitativeMeasurementCreate[]; quantitative_measurements?: IQuantitativeMeasurementCreate[]; - captures?: ICreateCritterCapture[]; + locations?: ILocationCreate[]; + captures?: ICreateBulkCritterCapture[]; mortality?: ICreateCritterMortality; markings?: ICreateCritterMarking[]; collections?: ICreateCritterCollectionUnit[]; diff --git a/app/src/hooks/api/useAnimalApi.ts b/app/src/hooks/api/useAnimalApi.ts index 9adf107fee..caf5bb9882 100644 --- a/app/src/hooks/api/useAnimalApi.ts +++ b/app/src/hooks/api/useAnimalApi.ts @@ -1,4 +1,4 @@ -import { AxiosInstance } from 'axios'; +import { AxiosInstance, AxiosProgressEvent, CancelTokenSource } from 'axios'; import { IAnimalsAdvancedFilters } from 'features/summary/tabular-data/animal/AnimalsListFilterForm'; import { IFindAnimalsResponse, IGetCaptureMortalityGeometryResponse } from 'interfaces/useAnimalApi.interface'; import qs from 'qs'; @@ -54,7 +54,76 @@ const useAnimalApi = (axios: AxiosInstance) => { return data; }; - return { getCaptureMortalityGeometry, findAnimals }; + /** + * Uploads attachments for a Critter Capture and deletes existing attachments if provided. + * + * @async + * @param {*} params - Upload parameters. + * @returns {*} Promise + */ + const uploadCritterCaptureAttachments = async (params: { + projectId: number; + surveyId: number; + critterId: number; + critterbaseCaptureId: string; + files: File[]; + deleteIds?: number[]; + cancelTokenSource?: CancelTokenSource; + onProgress?: (progressEvent: AxiosProgressEvent) => void; + }) => { + const fileData = new FormData(); + + /** + * Add all the files to the request FormData + * + * Note: Multer expecting a single key of 'media' for the array of files, + * using `media[index]` will not work. + */ + params.files.forEach((file) => { + fileData.append(`media`, file); + }); + + // Add the existing attachment ids to delete + if (params.deleteIds?.length) { + params.deleteIds.forEach((id, idx) => { + fileData.append(`delete_ids[${idx}]`, id.toString()); + }); + } + + await axios.post( + `/api/project/${params.projectId}/survey/${params.surveyId}/critters/${params.critterId}/captures/${params.critterbaseCaptureId}/attachments/upload`, + fileData, + { + cancelToken: params.cancelTokenSource?.token, + onUploadProgress: params.onProgress + } + ); + }; + + /** + * Deletes all attachments for a Critter Capture. + * + * @async + * @param {*} params - Delete parameters. + * @returns {*} Promise + */ + const deleteCaptureAttachments = async (params: { + projectId: number; + surveyId: number; + critterId: number; + critterbaseCaptureId: string; + }) => { + await axios.delete( + `/api/project/${params.projectId}/survey/${params.surveyId}/critters/${params.critterId}/captures/${params.critterbaseCaptureId}/attachments` + ); + }; + + return { + getCaptureMortalityGeometry, + findAnimals, + uploadCritterCaptureAttachments, + deleteCaptureAttachments + }; }; export default useAnimalApi; diff --git a/app/src/hooks/useS3Download.tsx b/app/src/hooks/useS3Download.tsx new file mode 100644 index 0000000000..002d7e435d --- /dev/null +++ b/app/src/hooks/useS3Download.tsx @@ -0,0 +1,41 @@ +import { AttachmentsI18N } from 'constants/i18n'; +import { DialogContext } from 'contexts/dialogContext'; +import { useContext } from 'react'; +import { APIError } from './api/useAxios'; + +/** + * Hook for downloading a file from a S3 key. + * + */ +export const useS3FileDownload = () => { + const dialogContext = useContext(DialogContext); + + /** + * Download a file from a S3 key. + * + * @param {string} s3KeyOrPromise - The S3 key or a promise that resolves to the S3 key. + * @returns {*} {Promise} + */ + const downloadS3File = async (s3KeyOrPromise: Promise | string) => { + try { + const s3Key = await s3KeyOrPromise; + + window.open(s3Key); + } catch (error) { + const apiError = error as APIError; + + dialogContext.setErrorDialog({ + open: true, + onOk: () => dialogContext.setErrorDialog({ open: false }), + onClose: () => dialogContext.setErrorDialog({ open: false }), + dialogTitle: AttachmentsI18N.downloadErrorTitle, + dialogText: AttachmentsI18N.downloadErrorText, + dialogErrorDetails: apiError.errors + }); + } + }; + + return { + downloadS3File + }; +}; diff --git a/app/src/interfaces/useCritterApi.interface.ts b/app/src/interfaces/useCritterApi.interface.ts index 7e3e31db6a..64fdcdd73c 100644 --- a/app/src/interfaces/useCritterApi.interface.ts +++ b/app/src/interfaces/useCritterApi.interface.ts @@ -89,10 +89,22 @@ export interface IMeasurementsUpdate { } export interface ICreateCaptureRequest extends IMarkings, IMeasurementsCreate { + attachments: { + capture_attachments: { + create: Record; + delete?: never; + }; + }; capture: ICapturePostData; } export interface IEditCaptureRequest extends IMarkings, IMeasurementsUpdate { + attachments: { + capture_attachments: { + create: Record; + delete: number[]; + }; + }; capture: ICapturePostData; } @@ -109,6 +121,22 @@ export interface ICollectionUnitMultiTsnResponse { categories: ICollectionCategory[]; } +interface ICritterAttachmentBase { + uuid: string; + critter_id: number; + file_type: string; + file_name: string; + file_size: number; + title: string | null; + description: string | null; + key: string; +} + +export type ICritterCaptureAttachment = { + critter_capture_attachment_id: number; + critterbase_capture_id: string; +} & ICritterAttachmentBase; + export interface ICollectionCategory { collection_category_id: string; category_name: string; @@ -152,6 +180,19 @@ type ILocationResponse = { wmu_id: string | null; }; +export type ILocationCreate = { + location_id?: string; + latitude: number; + longitude: number; + coordinate_uncertainty?: number | null; + coordinate_uncertainty_unit?: string; + temperature?: number | null; + location_comment?: string | null; + region_env_id?: string | null; + region_nr_id?: string | null; + wmu_id?: string | null; +}; + export type ICaptureResponse = { capture_id: string; capture_date: string; @@ -299,6 +340,9 @@ export type ICritterDetailedResponse = { }; family_parent: IFamilyParentResponse[]; family_child: IFamilyChildResponse[]; + attachments: { + capture_attachments: ICritterCaptureAttachment[]; + }; }; export interface ICritterSimpleResponse { diff --git a/database/src/migrations/20240905000000_capture_mortality_attachment_tables.ts b/database/src/migrations/20240905000000_capture_mortality_attachment_tables.ts new file mode 100644 index 0000000000..6c28ba0ce0 --- /dev/null +++ b/database/src/migrations/20240905000000_capture_mortality_attachment_tables.ts @@ -0,0 +1,158 @@ +import { Knex } from 'knex'; + +/** + * Create 2 new tables: + * + * CRITTER CAPTURE ATTACHMENT + * - critter_capture_attachment + * + * CRITTER MORTALITY ATTACHMENT + * - critter_mortality_attachment + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + ---------------------------------------------------------------------------------------- + -- Create capture and mortality attachment tables + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub_dapi_v1; + + SET SEARCH_PATH=biohub, public; + + ---------------------------------------------------------------------------------------- + -- Create capture attachment table + ---------------------------------------------------------------------------------------- + + CREATE TABLE critter_capture_attachment ( + critter_capture_attachment_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + uuid uuid DEFAULT public.gen_random_uuid() NOT NULL, + critter_id integer NOT NULL, + critterbase_capture_id uuid NOT NULL, + file_type varchar(300) NOT NULL, + file_name varchar(300), + title varchar(300), + description varchar(250), + key varchar(1000) NOT NULL, + file_size integer, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + + CONSTRAINT critter_capture_attachment_pk PRIMARY KEY (critter_capture_attachment_id) + ); + + COMMENT ON TABLE critter_capture_attachment IS 'A list of critter capture files (ex: critter capture files like pdf or jpeg).'; + COMMENT ON COLUMN critter_capture_attachment.critter_capture_attachment_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN critter_capture_attachment.uuid IS 'The universally unique identifier for the record.'; + COMMENT ON COLUMN critter_capture_attachment.critter_id IS 'Foreign key reference to the SIMS critter table.'; + COMMENT ON COLUMN critter_capture_attachment.critterbase_capture_id IS 'Critterbase capture identifier. External reference the Critterbase capture table.'; + COMMENT ON COLUMN critter_capture_attachment.file_type IS 'The attachment type. Attachment type examples include keyx, cfg, etc.'; + COMMENT ON COLUMN critter_capture_attachment.file_name IS 'The name of the file attachment.'; + COMMENT ON COLUMN critter_capture_attachment.title IS 'The title of the file.'; + COMMENT ON COLUMN critter_capture_attachment.description IS 'The description of the record.'; + COMMENT ON COLUMN critter_capture_attachment.key IS 'The identifying key to the file in the storage system.'; + COMMENT ON COLUMN critter_capture_attachment.file_size IS 'The size of the file in bytes.'; + COMMENT ON COLUMN critter_capture_attachment.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN critter_capture_attachment.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN critter_capture_attachment.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN critter_capture_attachment.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN critter_capture_attachment.revision_count IS 'Revision count used for concurrency control.'; + + -- Add foreign key constraints + ALTER TABLE critter_capture_attachment + ADD CONSTRAINT critter_capture_attachment_fk1 + FOREIGN KEY (critter_id) + REFERENCES critter(critter_id); + + -- Add indexes for foreign keys + CREATE INDEX critter_capture_attachment_idx1 ON critter_capture_attachment(critter_id); + CREATE UNIQUE INDEX critter_capture_attachment_idx2 ON critter_capture_attachment(critter_id, critterbase_capture_id, file_name); + + ---------------------------------------------------------------------------------------- + -- Create audit and journal triggers + ---------------------------------------------------------------------------------------- + + CREATE TRIGGER audit_critter_capture_attachment BEFORE INSERT OR UPDATE OR DELETE ON critter_capture_attachment FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_critter_capture_attachment AFTER INSERT OR UPDATE OR DELETE ON critter_capture_attachment FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + + + ---------------------------------------------------------------------------------------- + -- Create mortality attachment table + ---------------------------------------------------------------------------------------- + + CREATE TABLE critter_mortality_attachment ( + critter_mortality_attachment_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + uuid uuid DEFAULT public.gen_random_uuid() NOT NULL, + critter_id integer NOT NULL, + critterbase_mortality_id uuid NOT NULL, + file_type varchar(300) NOT NULL, + file_name varchar(300), + title varchar(300), + description varchar(250), + key varchar(1000) NOT NULL, + file_size integer, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + + CONSTRAINT critter_mortality_attachment_pk PRIMARY KEY (critter_mortality_attachment_id) + ); + + COMMENT ON TABLE critter_mortality_attachment IS 'A list of critter mortality files (ex: critter mortality files like pdf or jpeg).'; + COMMENT ON COLUMN critter_mortality_attachment.critter_mortality_attachment_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN critter_mortality_attachment.uuid IS 'The universally unique identifier for the record.'; + COMMENT ON COLUMN critter_mortality_attachment.critter_id IS 'Foreign key reference to the SIMS critter table.'; + COMMENT ON COLUMN critter_mortality_attachment.critterbase_mortality_id IS 'Critterbase mortality identifier. External reference to the Critterbase mortality table.'; + COMMENT ON COLUMN critter_mortality_attachment.file_type IS 'The attachment type. Attachment type examples include keyx, cfg, etc.'; + COMMENT ON COLUMN critter_mortality_attachment.file_name IS 'The name of the file attachment.'; + COMMENT ON COLUMN critter_mortality_attachment.title IS 'The title of the file.'; + COMMENT ON COLUMN critter_mortality_attachment.description IS 'The description of the record.'; + COMMENT ON COLUMN critter_mortality_attachment.key IS 'The identifying key to the file in the storage system.'; + COMMENT ON COLUMN critter_mortality_attachment.file_size IS 'The size of the file in bytes.'; + COMMENT ON COLUMN critter_mortality_attachment.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN critter_mortality_attachment.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN critter_mortality_attachment.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN critter_mortality_attachment.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN critter_mortality_attachment.revision_count IS 'Revision count used for concurrency control.'; + + -- Add foreign key constraints + ALTER TABLE critter_mortality_attachment + ADD CONSTRAINT critter_mortality_attachment_fk1 + FOREIGN KEY (critter_id) + REFERENCES critter(critter_id); + + -- Add indexes for foreign keys + CREATE INDEX critter_mortality_attachment_idx1 ON critter_mortality_attachment(critter_id); + CREATE UNIQUE INDEX critter_mortality_attachment_idx2 ON critter_mortality_attachment(critter_id, critterbase_mortality_id, file_name); + + ---------------------------------------------------------------------------------------- + -- Create audit and journal triggers + ---------------------------------------------------------------------------------------- + + CREATE TRIGGER audit_critter_mortality_attachment BEFORE INSERT OR UPDATE OR DELETE ON critter_mortality_attachment FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_critter_mortality_attachment AFTER INSERT OR UPDATE OR DELETE ON critter_mortality_attachment FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + + ---------------------------------------------------------------------------------------- + -- Create views + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW critter_capture_attachment as SELECT * FROM biohub.critter_capture_attachment; + CREATE OR REPLACE VIEW critter_mortality_attachment as SELECT * FROM biohub.critter_mortality_attachment; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +}