diff --git a/.env.development b/.env.development index 56b88095a..80dd7dde4 100644 --- a/.env.development +++ b/.env.development @@ -2,7 +2,8 @@ VITE_TRACTION_BASE_URL=http://traction VITE_TRACTION_GRAPHQL_URL=http://traction/graphql VITE_PRINTMYBARCODE_BASE_URL=http://printmybarcode VITE_SAMPLEEXTRACTION_BASE_URL=http://sampleextraction -VITE_LABWHERE_BASE_URL=http://labwhere +VITE_LABWHERE_BASE_URL=http://localhost:3003 VITE_SEQUENCESCAPE_API_KEY=development VITE_LOG=true -VITE_ENVIRONMENT=development \ No newline at end of file +VITE_ENVIRONMENT=development +VITE_DESTROYED_LOCATION_BARCODE=lw-destroyed-217 diff --git a/.env.production b/.env.production index 214eea82b..78b4b10cc 100644 --- a/.env.production +++ b/.env.production @@ -5,4 +5,5 @@ VITE_SAMPLEEXTRACTION_BASE_URL=REPLACE_VITE_SAMPLEEXTRACTION_BASE_URL VITE_SEQUENCESCAPE_BASE_URL=REPLACE_VITE_SEQUENCESCAPE_BASE_URL VITE_LABWHERE_BASE_URL=REPLACE_VITE_LABWHERE_BASE_URL VITE_SEQUENCESCAPE_API_KEY=REPLACE_VITE_SEQUENCESCAPE_API_KEY -VITE_ENVIRONMENT=REPLACE_VITE_ENVIRONMENT \ No newline at end of file +VITE_ENVIRONMENT=REPLACE_VITE_ENVIRONMENT +VITE_DESTROYED_LOCATION_BARCODE=REPLACE_VITE_LABWHERE_DESTROY_LOCATION_BARCODE diff --git a/.env.uat b/.env.uat index 311e83754..78b4b10cc 100644 --- a/.env.uat +++ b/.env.uat @@ -6,3 +6,4 @@ VITE_SEQUENCESCAPE_BASE_URL=REPLACE_VITE_SEQUENCESCAPE_BASE_URL VITE_LABWHERE_BASE_URL=REPLACE_VITE_LABWHERE_BASE_URL VITE_SEQUENCESCAPE_API_KEY=REPLACE_VITE_SEQUENCESCAPE_API_KEY VITE_ENVIRONMENT=REPLACE_VITE_ENVIRONMENT +VITE_DESTROYED_LOCATION_BARCODE=REPLACE_VITE_LABWHERE_DESTROY_LOCATION_BARCODE diff --git a/src/services/labwhere/client.js b/src/services/labwhere/client.js index 4d42c2697..b0a67f4c6 100644 --- a/src/services/labwhere/client.js +++ b/src/services/labwhere/client.js @@ -1,11 +1,18 @@ import { extractLocationsForLabwares } from './helpers.js' import { FetchWrapper } from '@/api/FetchWrapper.js' +import { + exhaustLibrayVolume, + formatAndTransformLibraries, +} from '@/stores/utilities/pacbioLibraries.js' +import { getPacbioLibraryResources } from '@/services/traction/PacbioLibrary.js' const labwhereFetch = FetchWrapper(import.meta.env['VITE_LABWHERE_BASE_URL'], 'LabWhere') +const destroyLocation = import.meta.env['VITE_DESTROYED_LOCATION_BARCODE'] /** * Fetches the locations of labwares from LabWhere based on provided barcodes. * * @param {string[]} labwhereBarcodes - An array of labware barcodes to search for. + * @param {Object} [fetchWrapper=labwhereFetch] - The fetch wrapper to use for the request (optional). * @returns {Promise<{success: boolean, errors: string[], data: Object}>} - A promise that resolves to an object containing the success status, any errors, and the data (locations). * * @example @@ -18,7 +25,7 @@ const labwhereFetch = FetchWrapper(import.meta.env['VITE_LABWHERE_BASE_URL'], 'L * } * }); */ -const getLabwhereLocations = async (labwhereBarcodes) => { +const getLabwhereLocations = async (labwhereBarcodes, fetchWrapper = labwhereFetch) => { // If no barcodes are provided, return a failed response. if (!labwhereBarcodes || labwhereBarcodes.length === 0) { return { success: false, errors: ['No barcodes provided'], data: {} } @@ -28,7 +35,7 @@ const getLabwhereLocations = async (labwhereBarcodes) => { params.append('barcodes[]', barcode) }) - const response = await labwhereFetch.post('/api/labwares/searches', params, 'multipart/form-data') + const response = await fetchWrapper.post('/api/labwares/searches', params, 'multipart/form-data') if (response.success) { response.data = extractLocationsForLabwares(response.data, labwhereBarcodes) @@ -46,8 +53,9 @@ const getLabwhereLocations = async (labwhereBarcodes) => { * * @param {string} userCode - The user code or swipecard. * @param {string} locationBarcode - The barcode of the location where labware will be stored. - * @param {string} labwareBarcodes - The barcodes of the labware to be stored, separated by newlines. + * @param {string} labwareBarcodes - The barcodes of the labware (library barcode or the plate / tube barcode for samples) to be stored, separated by newlines. * @param {number|null} [startPosition=null] - The starting position for storing the labware (optional). + * @param {Object} [fetchWrapper=labwhereFetch] - The fetch wrapper to use for the request (optional). * @returns {Promise<{success: boolean, errors: string[]}>} - A promise that resolves to an object containing the success status, any errors, and the data. * * @example @@ -68,6 +76,7 @@ const scanBarcodesInLabwhereLocation = async ( locationBarcode, labwareBarcodes, startPosition, + fetchWrapper = labwhereFetch, ) => { if (!userCode || !labwareBarcodes) { return { success: false, errors: ['Required parameters are missing for the Scan In operation'] } @@ -82,12 +91,61 @@ const scanBarcodesInLabwhereLocation = async ( if (startPosition) { params['scan[start_position]'] = startPosition } - const response = await labwhereFetch.post( + const response = await fetchWrapper.post( '/api/scans', new URLSearchParams(params).toString(), 'application/x-www-form-urlencoded', ) return { success: response.success, errors: response.errors, message: response.data.message } } +/** + * Exhausts the volume of libraries if the location barcode matches the destroy location. + * + * @param {string} locationBarcode - The barcode of the location. + * @param {Array} labwareBarcodes - The barcodes of the labware. + * @returns {Promise} - An object containing the success status and the exhausted libraries. + */ +const exhaustLibraryVolumeIfDestroyed = async (locationBarcode, labwareBarcodes) => { + if (locationBarcode !== destroyLocation) return { success: false } + let librariesToDestroy = [] + + //Fetch libraries by filter key + const fetchAndMergeLibraries = async (barcodes, filterKey) => { + const filterOptions = { filter: { [filterKey]: barcodes.join(',') } } + + const { success, libraries, tubes, tags, requests } = + await getPacbioLibraryResources(filterOptions) + if (success) { + librariesToDestroy = [ + ...librariesToDestroy, + ...formatAndTransformLibraries(libraries, tubes, tags, requests), + ] + } + } -export { getLabwhereLocations, scanBarcodesInLabwhereLocation } + //Fetch libraries by source_identifier + await fetchAndMergeLibraries(labwareBarcodes, 'source_identifier') + const barcodesNotFetchedAsSourceIdentifer = labwareBarcodes.filter( + (barcode) => !librariesToDestroy.some((library) => library.source_identifier === barcode), + ) + // If not all libraries are found by source_identifier, try fetching by barcode + if (barcodesNotFetchedAsSourceIdentifer.length > 0) { + //Fetch libraries which are not found by barcode + await fetchAndMergeLibraries(barcodesNotFetchedAsSourceIdentifer, 'barcode') + } + // If no libraries are found, return a failed response + if (!librariesToDestroy.length) return { success: false } + const exhaustedLibraries = [] + + // Exhaust the volume of libraries + await Promise.all( + librariesToDestroy.map(async (library) => { + const { success } = await exhaustLibrayVolume(library) + if (success) { + exhaustedLibraries.push(library) + } + }), + ) + return { success: exhaustedLibraries.length > 0, exhaustedLibraries } +} +export { getLabwhereLocations, scanBarcodesInLabwhereLocation, exhaustLibraryVolumeIfDestroyed } diff --git a/src/services/traction/PacbioLibrary.js b/src/services/traction/PacbioLibrary.js new file mode 100644 index 000000000..8f9e75da3 --- /dev/null +++ b/src/services/traction/PacbioLibrary.js @@ -0,0 +1,103 @@ +import { handleResponse } from '@/api/ResponseHelper.js' +import { groupIncludedByResource, dataToObjectById } from '@/api/JsonApi.js' +import useRootStore from '@/stores/index.js' + +/** + * Fetches all libraries. + * + * @param {Object} getPacbioLibraryResources - The options to fetch libraries with. + * The options include page, filter, and include. + * e.g { page: { "size": "24", "number": "1"}, filter: { source_identifier: 'sample1' }, include: 'request,tag,tube' } + */ +async function getPacbioLibraryResources(fetchOptions = {}) { + const includes = new Set(fetchOptions.include ? fetchOptions.include.split(',') : []) + const requiredIncludes = ['request', 'tag', 'tube'] + requiredIncludes.forEach((item) => includes.add(item)) + + const fetchOptionsDefaultInclude = { + ...fetchOptions, + include: Array.from(includes).join(','), + } + const request = useRootStore().api.traction.pacbio.libraries + const response = await handleResponse(request.get(fetchOptionsDefaultInclude)) + + const { success, body: { data, included = [], meta = {} } = {}, errors = [] } = response + let libraries = {}, + tubes = {}, + tags = {}, + requests = {} + if (success && data && data.length > 0) { + const { + tubes: included_tubes, + tags: included_tags, + requests: included_requests, + } = groupIncludedByResource(included) + libraries = dataToObjectById({ data, includeRelationships: true }) + tubes = dataToObjectById({ data: included_tubes }) + tags = dataToObjectById({ data: included_tags }) + requests = dataToObjectById({ data: included_requests }) + } + return { success, data, errors, meta, libraries, tubes, tags, requests } +} + +/** + * + * @param {Integer | String} id - id of the library + * @param {Integer | String} pacbio_request_id - id of the pacbio request + * @param {String} template_prep_kit_box_barcode - barcode of the template prep kit box + * @param {Integer | String} tag_id - id of the tag + * @param {Float} concentration - concentration of the library + * @param {Float} volume - volume of the library + * @param {Float} insert_size - insert size of the library + * @returns {Object} - payload for creating a library + * if it is an update id is added otherwise pacbio_request_id is added + * + */ +const buildLibraryResourcePayload = ({ + id, + pacbio_request_id, + template_prep_kit_box_barcode, + tag_id, + concentration, + volume, + insert_size, +}) => { + const requiredAttributes = { + template_prep_kit_box_barcode, + tag_id, + concentration, + volume, + insert_size, + } + + const payload = { + data: { + type: 'libraries', + attributes: { + ...requiredAttributes, + primary_aliquot_attributes: { + ...requiredAttributes, + }, + }, + }, + } + + id ? (payload.data.id = id) : (payload.data.attributes.pacbio_request_id = pacbio_request_id) + + return payload +} + +/** + * Updates a library with the given fields and updates the store if successful. + * + * @param {Object} libraryFields - The fields of the library to update. + * @returns {Promise} - An object containing the success status and any errors. + */ +async function updatePacbioLibraryResource(libraryFields) { + const request = useRootStore().api.traction.pacbio.libraries + const promise = request.update(buildLibraryResourcePayload(libraryFields)) + const { success, errors } = await handleResponse(promise) + return { success, errors } +} + +export { getPacbioLibraryResources, updatePacbioLibraryResource, buildLibraryResourcePayload } diff --git a/src/stores/pacbioLibraries.js b/src/stores/pacbioLibraries.js index 97bf44680..686d2cac9 100644 --- a/src/stores/pacbioLibraries.js +++ b/src/stores/pacbioLibraries.js @@ -1,27 +1,16 @@ import { defineStore } from 'pinia' -import useRootStore from '@/stores' +import useRootStore from '@/stores/index.js' import { handleResponse } from '@/api/ResponseHelper.js' -import { groupIncludedByResource, dataToObjectById } from '@/api/JsonApi.js' +import { groupIncludedByResource } from '@/api/JsonApi.js' import { usePacbioRootStore } from '@/stores/pacbioRoot.js' -import { libraryPayload } from '@/stores//utilities/pacbioLibraries.js' - -/** - * @function validateFields - * @param {Object} library - The library object to validate. - * @returns {boolean} Returns true if all required fields are present and truthy in the library object, false otherwise. - * @description Validates that the required fields are present in the given library object. - * The required fields are 'id', 'template_prep_kit_box_barcode', 'volume' and 'concentration'. - * The 'tag' and 'insert_size' fields are optional. - */ -const validateFields = (library) => { - const requiredAttributes = ['id', 'template_prep_kit_box_barcode', 'volume', 'concentration'] - const errors = requiredAttributes.filter((field) => !library[field]) - - return { - success: errors.length === 0, - errors: 'Missing required field(s)', - } -} +import { + getPacbioLibraryResources, + buildLibraryResourcePayload, +} from '@/services/traction/PacbioLibrary.js' +import { + validateAndUpdateLibrary, + formatAndTransformLibraries, +} from '@/stores/utilities/pacbioLibraries.js' /** * Importing `defineStore` function from 'pinia' library. @@ -60,13 +49,7 @@ export const usePacbioLibrariesStore = defineStore('pacbioLibraries', { */ librariesArray: (state) => { const pacbioRootStore = usePacbioRootStore() - return Object.values(state.libraries) - .filter((library) => library.tube) - .map((library) => { - const { id, request, tag_id, tag, tube, ...attributes } = library - const tagId = tag_id ?? tag - - /*Get the tag group ID from the library's tag ID or from the tag in pacbioRoot store(where all pacbio tags are kept). Why is this required? + /*Get the tag group ID from the library's tag ID or from the tag in pacbioRoot store(where all pacbio tags are kept). Why is this required? The librariesArray is called in multiple places (in create and edit context) to get the libraries. Therefore, librariesArray needs to search for the tag first in tags. If not found, it should then look for it in 'pacbioRoot' store tags. @@ -74,23 +57,8 @@ export const usePacbioLibrariesStore = defineStore('pacbioLibraries', { which may not happen in all the places where it's called. Hence, a search in both places is required to ensure that librariesArray returns the correct tag associated with all libraries."*/ - - const tagGroupId = state.tags[tagId] - ? state.tags[tagId].group_id - : pacbioRootStore.tags[tagId] - ? pacbioRootStore.tags[tagId].group_id - : '' - - return { - id, - tag_id: String(tagId), - tube, - ...attributes, - tag_group_id: tagGroupId ?? '', - sample_name: state.requests[request].sample_name, - barcode: state.tubes[tube].barcode, - } - }) + const tags = { ...state.tags, ...pacbioRootStore.tags } + return formatAndTransformLibraries(state.libraries, state.tubes, tags, state.requests) }, }, actions: { @@ -113,7 +81,7 @@ export const usePacbioLibrariesStore = defineStore('pacbioLibraries', { }) { const rootState = useRootStore() const request = rootState.api.traction.pacbio.libraries - const payload = libraryPayload({ + const payload = buildLibraryResourcePayload({ pacbio_request_id, template_prep_kit_box_barcode, tag_id, @@ -155,47 +123,28 @@ export const usePacbioLibrariesStore = defineStore('pacbioLibraries', { * @param {number} page - The page number to fetch from the server. * @returns {Promise<{success: boolean, errors: Array}>} - A promise that resolves to an object containing a success boolean and an array of errors. */ - async fetchLibraries(filter = {}, page = {}) { - const rootStore = useRootStore() - const pacbioLibraries = rootStore.api.traction.pacbio.libraries - const promise = pacbioLibraries.get({ - page, - filter, - include: 'request,tag,tube', - }) - const response = await handleResponse(promise) - - const { success, body: { data, included = [], meta = {} } = {}, errors = [] } = response - + async fetchLibraries(filterOptions) { + const { success, data, meta, errors, libraries, tubes, tags, requests } = + await getPacbioLibraryResources({ + ...filterOptions, + }) if (success && data.length > 0) { - const { tubes, tags, requests } = groupIncludedByResource(included) - this.libraries = dataToObjectById({ data, includeRelationships: true }) - this.tubes = dataToObjectById({ data: tubes }) - this.tags = dataToObjectById({ data: tags }) - this.requests = dataToObjectById({ data: requests }) + this.libraries = libraries + this.tubes = tubes + this.tags = tags + this.requests = requests } return { success, errors, meta } }, /** - * Updates the library with matchingid with all given field values. - * @param {*} libraryFields - The fields to update the library with. - * * @returns {Promise} A promise that resolves to an object. - * The object has a 'success' property that is true if the library was updated successfully and false otherwise. - * If 'success' is false, the object also has an 'errors' property with a message describing the error. + * Updates a library with the given fields and updates the store if successful. + * + * @param {Object} libraryFields - The fields of the library to update. + * @returns {Promise} - An object containing the success status and any errors. */ async updateLibrary(libraryFields) { - //Validate the libraryFields to ensure that all required fields are present - const valid = validateFields(libraryFields) - if (!valid.success) { - return { success: false, errors: valid.errors } - } - - const rootStore = useRootStore() - const request = rootStore.api.traction.pacbio.libraries - const payload = libraryPayload(libraryFields) - const promise = request.update(payload) - const { success, errors } = await handleResponse(promise) + const { success, errors } = await validateAndUpdateLibrary(libraryFields) if (success) { //Update all fields of the library in the store with matching ID with the given values. this.libraries[libraryFields.id] = { diff --git a/src/stores/utilities/pacbioLibraries.js b/src/stores/utilities/pacbioLibraries.js index 514799fab..61a2891b1 100644 --- a/src/stores/utilities/pacbioLibraries.js +++ b/src/stores/utilities/pacbioLibraries.js @@ -1,48 +1,99 @@ +import { updatePacbioLibraryResource } from '@/services/traction/PacbioLibrary.js' + /** - * - * @param {Integer | String} id - id of the library - * @param {Integer | String} pacbio_request_id - id of the pacbio request - * @param {String} template_prep_kit_box_barcode - barcode of the template prep kit box - * @param {Integer | String} tag_id - id of the tag - * @param {Float} concentration - concentration of the library - * @param {Float} volume - volume of the library - * @param {Float} insert_size - insert size of the library - * @returns {Object} - payload for creating a library - * if it is an update id is added otherwise pacbio_request_id is added - * + * @function validateFields + * @param {Object} library - The library object to validate. + * @returns {boolean} Returns true if all required fields are present and truthy in the library object, false otherwise. + * @description Validates that the required fields are present in the given library object. + * The required fields are 'id', 'template_prep_kit_box_barcode', 'volume' and 'concentration'. + * The 'tag' and 'insert_size' fields are optional. */ -const libraryPayload = ({ - id, - pacbio_request_id, - template_prep_kit_box_barcode, - tag_id, - concentration, - volume, - insert_size, -}) => { - const requiredAttributes = { - template_prep_kit_box_barcode, - tag_id, - concentration, - volume, - insert_size, - } +const validateLibraryFields = (library) => { + const requiredAttributes = [ + 'id', + 'template_prep_kit_box_barcode', + 'volume', + 'concentration', + 'insert_size', + ] + const errors = requiredAttributes.filter( + (field) => library[field] === null || library[field] === '', + ) - const payload = { - data: { - type: 'libraries', - attributes: { - ...requiredAttributes, - primary_aliquot_attributes: { - ...requiredAttributes, - }, - }, - }, + return { + success: errors.length === 0, + errors: errors.length ? 'Missing required field(s)' : '', } +} + +/** + * Formats and transforms libraries. + * + * @param {Object} libraries - The libraries to format and transform. + * @param {Object} tubes - The tubes associated with the libraries. + * @param {Object} tags - The tags associated with the libraries. + * @param {Object} requests - The requests associated with the libraries. + * @returns {Array} - The formatted and transformed libraries. + */ +const formatAndTransformLibraries = (libraries, tubes, tags, requests) => + Object.values(libraries) + .filter((library) => library.tube) + .map((library) => { + const { id, request, tag_id, tag, tube, ...attributes } = library + const tagId = tag_id ?? tag + const tagGroupId = tags[tagId] ? (tags[tagId].group_id ?? '') : '' + return { + id, + tag_id: String(tagId), + tube, + ...attributes, + tag_group_id: tagGroupId, + sample_name: requests[request]?.sample_name, + barcode: tubes[tube]?.barcode, + } + }) + +/** + * Exhausts the volume of a library. + * + * @param {Object} library - The library to exhaust the volume of. + * @returns {Promise} - An object containing the success status and any errors. + */ +async function exhaustLibrayVolume(library) { + library.volume = library.used_volume + const { success, errors } = await validateAndUpdateLibrary(library) + return { success, errors } +} - id ? (payload.data.id = id) : (payload.data.attributes.pacbio_request_id = pacbio_request_id) +/** + * Checks if the library is exhausted. + * + * @param {Object} library - The library object to check. + * @param {number} library.volume - The current volume of the library. + * @param {number} library.used_volume - The used volume of the library. + * @returns {boolean} - Returns true if the library is exhausted, otherwise false. + */ +const isLibraryExhausted = (library) => library.volume === library.used_volume - return payload +/** + * Updates a library with the given fields and updates the store if successful. + * + * @param {Object} libraryFields - The fields of the library to update. + * @returns {Promise} - An object containing the success status and any errors. + */ +async function validateAndUpdateLibrary(libraryFields) { + //Validate the libraryFields to ensure that all required fields are present + const valid = validateLibraryFields(libraryFields) + if (!valid.success) { + return valid + } + const { success, errors } = await updatePacbioLibraryResource(libraryFields) + return { success, errors } } -export { libraryPayload } +export { + validateAndUpdateLibrary, + exhaustLibrayVolume, + formatAndTransformLibraries, + isLibraryExhausted, +} diff --git a/src/views/LabwhereReception.vue b/src/views/LabwhereReception.vue index c0c18eacf..2a18d57c9 100644 --- a/src/views/LabwhereReception.vue +++ b/src/views/LabwhereReception.vue @@ -97,7 +97,10 @@ * It displays a success message if the barcodes are stored successfully, or an error message if the submission fails. */ import { ref, reactive, computed } from 'vue' -import { scanBarcodesInLabwhereLocation } from '@/services/labwhere/client.js' +import { + scanBarcodesInLabwhereLocation, + exhaustLibraryVolumeIfDestroyed, +} from '@/services/labwhere/client.js' import useAlert from '@/composables/useAlert.js' const user_code = ref('') // User code or swipecard @@ -170,7 +173,17 @@ const scanBarcodesToLabwhere = async () => { start_position.value, ) if (response.success) { - showAlert(response.message, 'success') + let message = response.message + // Check if the library volume need to be exhausted + const { success, exhaustedLibraries } = await exhaustLibraryVolumeIfDestroyed( + location_barcode.value, + uniqueBarcodesArray.value, + ) + if (success && exhaustedLibraries.length > 0) { + const length = exhaustedLibraries.length + message += ` and sample volumes have been exhausted for ${length} ${length === 1 ? 'library' : 'libraries'}` + } + showAlert(message, 'success') } else { showAlert(response.errors.join('\n'), 'danger') } diff --git a/src/views/pacbio/PacbioLibraryIndex.vue b/src/views/pacbio/PacbioLibraryIndex.vue index ba5b7f55c..716963788 100644 --- a/src/views/pacbio/PacbioLibraryIndex.vue +++ b/src/views/pacbio/PacbioLibraryIndex.vue @@ -53,6 +53,22 @@ select-mode="multi" @row-selected="(items) => (state.selected = items)" > +