diff --git a/.env.tests b/.env.tests index 0fe7df5c..b335d6f4 100644 --- a/.env.tests +++ b/.env.tests @@ -10,11 +10,12 @@ KAFKA_PASSWORD=test KAFKA_TOPIC=TestTopic SCICAT_BASE_URL=http://localhost/some/api -SCICAT_LOGIN_ENDPOINT=/login -SCICAT_USERNAME=user -SCICAT_PASSWORD=pass +SCICAT_JWT=eyJhbGciOiJIUzI1NiIsInR5 SCICAT_PROPOSAL_TRIGGERING_STATUSES="SCHEDULING, ALLOCATED" +USER_OFFICE_GRAPHQL_URL=http://localhost:8080 +USER_OFFICE_JWT=some-token + SYNAPSE_SERVER_URL=https://server-scichat SYNAPSE_SERVER_NAME=serverName SYNAPSE_SERVICE_USER=user @@ -23,6 +24,7 @@ SYNAPSE_OAUTH_ISSUER=SAMPLE_ISSUER USER_OFFICE_CORE_EXCHANGE_NAME=user_office_backend.fanout PROPOSAL_CREATION_QUEUE_NAME=connector.proposal_creation.queue +EXPERIMENT_CREATION_QUEUE_NAME=connector.experiment_creation.queue CHATROOM_CREATION_QUEUE_NAME=consumer.chatroom_creation.queue FOLDER_CREATION_QUEUE_NAME=connector.proposals_folders_creation.queue VISA_QUEUE_NAME=dummy diff --git a/src/index.spec.ts b/src/index.spec.ts index 99a55385..44d2016d 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -6,6 +6,11 @@ jest.mock('express', () => { return jest.fn(() => express); }); +jest.mock('./queue/consumers/scicat/scicatProposal/utils/scicatApi', () => ({ + scicatApi: { + serviceUsername: 'testuser', + }, +})); jest.mock('@user-office-software/duo-logger'); jest.mock('./middlewares/metrics/metrics', () => jest.fn()); diff --git a/src/index.ts b/src/index.ts index 23409f92..86a62ee4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,9 @@ async function bootstrap() { const enableScicatProposalUpsert = str2Bool( process.env.ENABLE_SCICAT_PROPOSAL_UPSERT as string ); + const enableScicatExperimentUpsert = str2Bool( + process.env.ENABLE_SCICAT_EXPERIMENT_UPSERT as string + ); const enableScichatRoomCreation = str2Bool( process.env.ENABLE_SCICHAT_ROOM_CREATION as string ); @@ -55,6 +58,7 @@ async function bootstrap() { logger.logInfo('Services configuration', { SciCat_Proposal_Upsert: enableScicatProposalUpsert, + SciCat_Experiment_Upsert: enableScicatExperimentUpsert, Scichat_Room_Creation: enableScichatRoomCreation, Proposal_Folders_Creation: enableProposalFoldersCreation, Nicos_to_Scichat_Messages: enableNicosToScichatMessages, @@ -75,6 +79,7 @@ async function bootstrap() { if ( enableScicatProposalUpsert || + enableScicatExperimentUpsert || enableScichatRoomCreation || enableProposalFoldersCreation || enableMoodleFoldersCreation || diff --git a/src/models/Event.ts b/src/models/Event.ts index 2ed1cff6..158124a4 100644 --- a/src/models/Event.ts +++ b/src/models/Event.ts @@ -4,4 +4,6 @@ export enum Event { PROPOSAL_UPDATED = 'PROPOSAL_UPDATED', VISIT_CREATED = 'VISIT_CREATED', VISIT_DELETED = 'VISIT_DELETED', + EXPERIMENT_CREATED = 'EXPERIMENT_CREATED', + EXPERIMENT_UPDATED = 'EXPERIMENT_UPDATED', } diff --git a/src/models/ProposalMessage.ts b/src/models/ProposalMessage.ts index b2955ae5..e4e7e68b 100644 --- a/src/models/ProposalMessage.ts +++ b/src/models/ProposalMessage.ts @@ -1,4 +1,4 @@ -import { ProposalUser } from './../queue/consumers/scicat/scicatProposal/dto'; +import { ProposalUser } from '../queue/consumers/scicat/scicatProposal/dto'; export type Instrument = { id: number; @@ -6,6 +6,11 @@ export type Instrument = { allocatedTime: number; }; +export type Sample = { + id: number; + title: string; +}; + export interface InstrumentDto { _id: string; id: string; @@ -34,16 +39,27 @@ export enum ProposalStatusDefaultShortCodes { } export type ProposalMessageData = { - proposalPk: number; - shortCode: string; - title: string; abstract: string; callId: number; - newStatus?: ProposalStatusDefaultShortCodes; - submitted: boolean; + instruments?: Instrument[]; members: ProposalUser[]; - dataAccessUsers?: ProposalUser[]; - visitors?: ProposalUser[]; + dataAccessUsers: ProposalUser[]; + visitors: ProposalUser[]; + newStatus?: string; + proposalPk: number; proposer?: ProposalUser; - instruments?: Instrument[]; + shortCode: string; + title: string; + submitted: boolean; + samples?: Sample[]; +}; + +export type ExperimentMessageData = { + experimentId: string; + experimentPk: number; + startsAt: Date; + endsAt: Date; + status: string; + proposal?: ProposalMessageData; + samples?: Sample[]; }; diff --git a/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts index 0e1d5ade..a88186b0 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts @@ -1,256 +1,52 @@ import { logger } from '@user-office-software/duo-logger'; +import { upsertSamplesInScicat } from './upsertSampleInScicat'; import { - Instrument, - InstrumentDto, + ExperimentMessageData, + ProposalMessageData, } from '../../../../../models/ProposalMessage'; -import { ValidProposalMessageData } from '../../../utils/validateProposalMessage'; -import { CreateProposalDto, UpdateProposalDto } from '../dto'; - -const sciCatBaseUrl = process.env.SCICAT_BASE_URL; -const sciCatLoginEndpoint = process.env.SCICAT_LOGIN_ENDPOINT || '/Users/login'; -const sciCatUsername = process.env.SCICAT_USERNAME; -const sciCatPassword = process.env.SCICAT_PASSWORD; - -async function request( - url: string, - config: RequestInit -): Promise { - // NOTE: Node v18 comes with fetch API by default - const response = await fetch(url, config); - - if (!response.ok) { - return response.text().then((errorDetail) => { - throw new Error(errorDetail); - }); - } - - return (await response.json()) as TResponse; -} - -const getSciCatAccessToken = async () => { - const loginCredentials = { - username: sciCatUsername, - password: sciCatPassword, - }; - - // NOTE: We login every time when there is new message to get the access_token - const { access_token: sciCatAccessToken } = await request<{ - access_token: string; - }>(`${sciCatBaseUrl}${sciCatLoginEndpoint}`, { - method: 'POST', - body: JSON.stringify(loginCredentials), - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!sciCatAccessToken) { - throw new Error('No access token found'); - } - - return sciCatAccessToken; -}; - -const getCreateProposalDto = (proposalMessage: ValidProposalMessageData) => { - const createProposalDto: CreateProposalDto = { - proposalId: proposalMessage.shortCode, - title: proposalMessage.title, - pi_email: proposalMessage.proposer.email, - pi_firstname: proposalMessage.proposer.firstName, - pi_lastname: proposalMessage.proposer.lastName, - email: proposalMessage.proposer.email, - firstname: proposalMessage.proposer.firstName, - lastname: proposalMessage.proposer.lastName, - abstract: proposalMessage.abstract, - ownerGroup: proposalMessage.shortCode, - instrumentIds: [], - accessGroups: [], - startTime: new Date(), - endTime: new Date(), - MeasurementPeriodList: [], - metadata: {}, - }; - - return createProposalDto; -}; - -const getUpdateProposalDto = (proposalMessage: ValidProposalMessageData) => { - const updateProposalDto: UpdateProposalDto = { - title: proposalMessage.title, - pi_email: proposalMessage.proposer.email, - pi_firstname: proposalMessage.proposer.firstName, - pi_lastname: proposalMessage.proposer.lastName, - email: proposalMessage.proposer.email, - firstname: proposalMessage.proposer.firstName, - lastname: proposalMessage.proposer.lastName, - abstract: proposalMessage.abstract, - ownerGroup: proposalMessage.shortCode, - instrumentIds: [], - accessGroups: [], - startTime: new Date(), - endTime: new Date(), - MeasurementPeriodList: [], - metadata: {}, - }; - - return updateProposalDto; -}; - -const createProposal = async ( - proposalMessage: ValidProposalMessageData, - sciCatAccessToken: string -) => { - const url = `${sciCatBaseUrl}/Proposals`; - const createProposalDto = getCreateProposalDto(proposalMessage); - - logger.logInfo('POST', { url }); - logger.logInfo('Proposal data', { proposalData: createProposalDto }); - - // RabbitMQ message only provides shortCodes (instrument names). - // To persist proposals with proper references, we resolve those shortCodes to - // actual Instrument IDs from SciCat and store the instrumentIds in the record. - createProposalDto.instrumentIds = await getInstrumentIds( - proposalMessage.instruments - ); - - const createProposalResponse = await request(url, { - method: 'POST', - body: JSON.stringify(createProposalDto), - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${sciCatAccessToken}`, - }, - }); - - logger.logInfo('createProposalResponse', { createProposalResponse }); - - logger.logInfo('Proposal was created in scicat', { - proposalId: createProposalDto.proposalId, - }); -}; - -const updateProposal = async ( - proposalMessage: ValidProposalMessageData, - sciCatAccessToken: string -) => { - const url = `${sciCatBaseUrl}/Proposals/${proposalMessage.shortCode}`; - const updateProposalDto = getUpdateProposalDto(proposalMessage); - - // RabbitMQ message only provides shortCodes (instrument names). - // To persist proposals with proper references, we resolve those shortCodes to - // actual Instrument IDs from SciCat and store the instrumentIds in the record. - updateProposalDto.instrumentIds = await getInstrumentIds( - proposalMessage.instruments - ); - const updateProposalResponse = await request(url, { - method: 'PATCH', - body: JSON.stringify(updateProposalDto), - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${sciCatAccessToken}`, - }, - }); - - logger.logInfo('Patch', { url }); - logger.logInfo('Proposal data', { proposalData: updateProposalDto }); - logger.logInfo('updateProposalResponse', { updateProposalResponse }); - - logger.logInfo('Proposal was updated in scicat', { - proposalId: proposalMessage.shortCode, - }); -}; +import { + fetchUoExperiment, + fetchUoProposal, +} from '../../../../../services/userOfficeApi/uoApi'; +import { scicatApi } from '../utils/scicatApi'; -const checkProposalExists = async ( - proposalId: string, - sciCatAccessToken: string +export const upsertProposalInScicat = async ( + proposalMessage: ProposalMessageData ) => { - const url = `${sciCatBaseUrl}/Proposals/${proposalId}`; - const response = await request(url, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${sciCatAccessToken}`, - }, - }).catch((error) => { - try { - const parsedError = JSON.parse(error.message); - if (parsedError.statusCode === 404) { - return false; - } - } catch (reason) { - logger.logError('Error parsing error message', { - error, - reason, - }); - } - throw error; - }); + const proposal = await fetchUoProposal(proposalMessage.proposalPk); + const exists = await scicatApi.checkProposalExists(proposal.proposalId); - if (response) { - return true; + if (exists) { + logger.logInfo('Proposal already exists, updating...', { + proposalId: proposal.proposalId, + }); + await scicatApi.updateProposal(proposal); } else { - return false; - } -}; - -const getInstrumentIds = async (instruments: Instrument[]) => { - const sciCatAccessToken = await getSciCatAccessToken(); - const instrumentNames = instruments.map((inst) => inst.shortCode); - - const instrumentIds = []; - - for (const name of instrumentNames) { - const instrumentNameLowerCase = name.toLowerCase(); - - const filterString = JSON.stringify({ - where: { name: { ilike: instrumentNameLowerCase } }, + logger.logInfo('Proposal does not exist yet, creating...', { + proposalId: proposal.proposalId, }); - - const url = `${sciCatBaseUrl}/Instruments?filter=${encodeURIComponent(filterString)}`; - - try { - const res = await request(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${sciCatAccessToken}`, - }, - }); - - instrumentIds.push(res[0].pid); - } catch (error) { - logger.logError(`Error fetching instrument ID from scicat for ${name}`, { - error, - }); - } + await scicatApi.createProposal(proposal); } - - return instrumentIds; }; -const upsertProposalInScicat = async ( - proposalMessage: ValidProposalMessageData +export const upsertExperimentInScicat = async ( + experimentMessage: ExperimentMessageData ) => { - const sciCatAccessToken = await getSciCatAccessToken(); - - const proposalExists = await checkProposalExists( - proposalMessage.shortCode, - sciCatAccessToken - ); + const experiment = await fetchUoExperiment(experimentMessage.experimentPk); + const exists = await scicatApi.checkProposalExists(experiment.experimentId); - if (proposalExists) { - logger.logInfo('Proposal already exists, updating...', { - proposalId: proposalMessage.shortCode, + if (exists) { + logger.logInfo('Experiment already exists, updating...', { + proposalId: experiment.experimentId, }); - - updateProposal(proposalMessage, sciCatAccessToken); + await scicatApi.updateExperiment(experiment); } else { - logger.logInfo('Proposal does not exist yet, creating...', { - proposalId: proposalMessage.shortCode, + logger.logInfo('Experiment does not exist yet, creating...', { + proposalId: experiment.experimentId, }); - - createProposal(proposalMessage, sciCatAccessToken); + await scicatApi.createExperiment(experiment); } -}; -export { upsertProposalInScicat }; + await upsertSamplesInScicat(experiment); +}; diff --git a/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertSampleInScicat.ts b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertSampleInScicat.ts new file mode 100644 index 00000000..fbb2642b --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertSampleInScicat.ts @@ -0,0 +1,37 @@ +import { logger } from '@user-office-software/duo-logger'; + +import { UOExperimentDto } from '../../../../../services/userOfficeApi/type/uoExperiment.type'; +import { + getCreateScicatSampleDto, + getUpdateScicatSampleDto, +} from '../mappers/uoToScicatExperiment.mapper'; +import { scicatApi } from '../utils/scicatApi'; + +export const upsertSamplesInScicat = async (experiment: UOExperimentDto) => { + const samples = experiment.experimentSafety?.samples ?? []; + + for (const sample of samples) { + const sampleLookup = `${experiment.experimentId}-${sample.sampleId}`; + const existingSample = await scicatApi.findSampleByLookup(sampleLookup); + + if (existingSample) { + const existingHash = + existingSample.sampleCharacteristics?.['sample_hash']?.value; + const dto = getUpdateScicatSampleDto(sample, experiment); + const newHash = dto.sampleCharacteristics?.['sample_hash']?.value; + + if (existingHash === newHash) { + continue; + } + + logger.logInfo('Sample changed, updating...', { + sampleId: existingSample.sampleId, + }); + await scicatApi.updateSample(existingSample.sampleId!, dto); + } else { + logger.logInfo('Sample does not exist yet, creating...', {}); + const dto = getCreateScicatSampleDto(sample, experiment); + await scicatApi.createSample(dto); + } + } +}; diff --git a/src/queue/consumers/scicat/scicatProposal/consumers/ChatroomCreationQueueConsumer.ts b/src/queue/consumers/scicat/scicatProposal/consumers/ChatroomCreationQueueConsumer.ts index cc431a52..efde0b0b 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumers/ChatroomCreationQueueConsumer.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumers/ChatroomCreationQueueConsumer.ts @@ -2,7 +2,7 @@ import { ConsumerCallback } from '@user-office-software/duo-message-broker'; import { Event } from '../../../../../models/Event'; import { QueueConsumer } from '../../../QueueConsumer'; -import { hasTriggeringStatus } from '../../../utils/hasTriggeringStatus'; +import { hasTriggeringProposalStatus } from '../../../utils/hasTriggeringStatus'; import { hasTriggeringType } from '../../../utils/hasTriggeringType'; import { validateProposalMessage } from '../../../utils/validateProposalMessage'; import { createChatroom } from '../consumerCallbacks/createChatroom'; @@ -30,7 +30,7 @@ export class ChatroomCreationQueueConsumer extends QueueConsumer { return; } - const hasStatus = hasTriggeringStatus(message, triggeringStatuses); + const hasStatus = hasTriggeringProposalStatus(message, triggeringStatuses); if (!hasStatus) { return; diff --git a/src/queue/consumers/scicat/scicatProposal/consumers/ExperimentCreationQueueConsumer.ts b/src/queue/consumers/scicat/scicatProposal/consumers/ExperimentCreationQueueConsumer.ts new file mode 100644 index 00000000..ecda5324 --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/consumers/ExperimentCreationQueueConsumer.ts @@ -0,0 +1,47 @@ +import { ConsumerCallback } from '@user-office-software/duo-message-broker'; + +import { Event } from '../../../../../models/Event'; +import { QueueConsumer } from '../../../QueueConsumer'; +import { hasTriggringExperimentStatus } from '../../../utils/hasTriggeringStatus'; +import { hasTriggeringType } from '../../../utils/hasTriggeringType'; +import { validateExperimentMessage } from '../../../utils/validateExperimentMessage'; +import { upsertExperimentInScicat } from '../consumerCallbacks/upsertProposalInScicat'; + +const EXPERIMENT_EVENT_TYPES = [ + Event.EXPERIMENT_CREATED, + Event.EXPERIMENT_UPDATED, +]; + +const experimentTriggeringStatuses = + process.env.SCICAT_EXPERIMENT_TRIGGERING_STATUSES?.split(', '); + +export class ExperimentCreationQueueConsumer extends QueueConsumer { + getQueueName(): string { + return process.env.EXPERIMENT_CREATION_QUEUE_NAME as string; + } + + getExchangeName(): string { + return process.env.USER_OFFICE_CORE_EXCHANGE_NAME as string; + } + + onMessage: ConsumerCallback = async (type, message) => { + const hasExperimentType = hasTriggeringType(type, EXPERIMENT_EVENT_TYPES); + + if (!hasExperimentType) { + return; + } + + const hasExperimentStatus = hasTriggringExperimentStatus( + message, + experimentTriggeringStatuses + ); + + if (!hasExperimentStatus) { + return; + } + + const experimentMessage = validateExperimentMessage(message); + + upsertExperimentInScicat(experimentMessage); + }; +} diff --git a/src/queue/consumers/scicat/scicatProposal/consumers/FolderCreationQueueConsumer.spec.ts b/src/queue/consumers/scicat/scicatProposal/consumers/FolderCreationQueueConsumer.spec.ts index 059016a4..d339ca9f 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumers/FolderCreationQueueConsumer.spec.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumers/FolderCreationQueueConsumer.spec.ts @@ -9,13 +9,13 @@ jest.mock('../../../QueueConsumer', () => ({ import { MessageBroker } from '@user-office-software/duo-message-broker'; import { FolderCreationQueueConsumer } from './FolderCreationQueueConsumer'; -import { hasTriggeringStatus } from '../../../utils/hasTriggeringStatus'; +import { hasTriggeringProposalStatus } from '../../../utils/hasTriggeringStatus'; import { hasTriggeringType } from '../../../utils/hasTriggeringType'; describe('FolderCreationQueueConsumer', () => { it('should not throw an error when message does not have the correct type and status', async () => { (hasTriggeringType as jest.Mock).mockReturnValueOnce(false); - (hasTriggeringStatus as jest.Mock).mockReturnValueOnce(false); + (hasTriggeringProposalStatus as jest.Mock).mockReturnValueOnce(false); const consumer = new FolderCreationQueueConsumer({} as MessageBroker); @@ -28,7 +28,7 @@ describe('FolderCreationQueueConsumer', () => { it('should not throw an error when message does have the incorrect type and correct status', async () => { (hasTriggeringType as jest.Mock).mockReturnValueOnce(false); - (hasTriggeringStatus as jest.Mock).mockReturnValueOnce(true); + (hasTriggeringProposalStatus as jest.Mock).mockReturnValueOnce(true); const consumer = new FolderCreationQueueConsumer({} as MessageBroker); @@ -41,7 +41,7 @@ describe('FolderCreationQueueConsumer', () => { it('should not throw an error when message does have the correct type and incorrect status', async () => { (hasTriggeringType as jest.Mock).mockReturnValueOnce(true); - (hasTriggeringStatus as jest.Mock).mockReturnValueOnce(false); + (hasTriggeringProposalStatus as jest.Mock).mockReturnValueOnce(false); const consumer = new FolderCreationQueueConsumer({} as MessageBroker); @@ -53,7 +53,7 @@ describe('FolderCreationQueueConsumer', () => { }); it('should throw error when invalid message does have the correct type or status', async () => { - (hasTriggeringStatus as jest.Mock).mockReturnValueOnce(true); + (hasTriggeringProposalStatus as jest.Mock).mockReturnValueOnce(true); (hasTriggeringType as jest.Mock).mockReturnValueOnce(true); const consumer = new FolderCreationQueueConsumer({} as MessageBroker); diff --git a/src/queue/consumers/scicat/scicatProposal/consumers/FolderCreationQueueConsumer.ts b/src/queue/consumers/scicat/scicatProposal/consumers/FolderCreationQueueConsumer.ts index fad6dc2e..ce2fca8d 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumers/FolderCreationQueueConsumer.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumers/FolderCreationQueueConsumer.ts @@ -2,7 +2,7 @@ import { ConsumerCallback } from '@user-office-software/duo-message-broker'; import { Event } from '../../../../../models/Event'; import { QueueConsumer } from '../../../QueueConsumer'; -import { hasTriggeringStatus } from '../../../utils/hasTriggeringStatus'; +import { hasTriggeringProposalStatus } from '../../../utils/hasTriggeringStatus'; import { hasTriggeringType } from '../../../utils/hasTriggeringType'; import { validateProposalMessage } from '../../../utils/validateProposalMessage'; import { proposalFoldersCreation } from '../consumerCallbacks/proposalFoldersCreation'; @@ -28,7 +28,7 @@ export class FolderCreationQueueConsumer extends QueueConsumer { return; } - const hasStatus = hasTriggeringStatus(message, triggeringStatuses); + const hasStatus = hasTriggeringProposalStatus(message, triggeringStatuses); if (!hasStatus) { return; diff --git a/src/queue/consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer.ts b/src/queue/consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer.ts index e6f790c2..0a700cb9 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer.ts @@ -2,17 +2,17 @@ import { ConsumerCallback } from '@user-office-software/duo-message-broker'; import { Event } from '../../../../../models/Event'; import { QueueConsumer } from '../../../QueueConsumer'; -import { hasTriggeringStatus } from '../../../utils/hasTriggeringStatus'; +import { hasTriggeringProposalStatus } from '../../../utils/hasTriggeringStatus'; import { hasTriggeringType } from '../../../utils/hasTriggeringType'; import { validateProposalMessage } from '../../../utils/validateProposalMessage'; import { upsertProposalInScicat } from '../consumerCallbacks/upsertProposalInScicat'; -const EVENT_TYPES = [ +const PROPOSAL_EVENT_TYPES = [ Event.PROPOSAL_STATUS_ACTION_EXECUTED, Event.PROPOSAL_UPDATED, ]; -const triggeringStatuses = +const proposalTriggeringStatuses = process.env.SCICAT_PROPOSAL_TRIGGERING_STATUSES?.split(', '); export class ProposalCreationQueueConsumer extends QueueConsumer { @@ -25,20 +25,22 @@ export class ProposalCreationQueueConsumer extends QueueConsumer { } onMessage: ConsumerCallback = async (type, message) => { - const hasType = hasTriggeringType(type, EVENT_TYPES); + const hasProposalType = hasTriggeringType(type, PROPOSAL_EVENT_TYPES); - if (!hasType) { + if (!hasProposalType) { return; } - const hasStatus = hasTriggeringStatus(message, triggeringStatuses); + const hasProposalStatus = hasTriggeringProposalStatus( + message, + proposalTriggeringStatuses + ); - if (!hasStatus) { + if (!hasProposalStatus) { return; } const proposalMessage = validateProposalMessage(message); - upsertProposalInScicat(proposalMessage); }; } diff --git a/src/queue/consumers/scicat/scicatProposal/dto.ts b/src/queue/consumers/scicat/scicatProposal/dto.ts index 8aa79b07..c882d922 100644 --- a/src/queue/consumers/scicat/scicatProposal/dto.ts +++ b/src/queue/consumers/scicat/scicatProposal/dto.ts @@ -1,40 +1,3 @@ -export type CreateProposalDto = { - ownerGroup: string; - accessGroups: string[]; - proposalId: string; - pi_email: string; - pi_firstname: string; - pi_lastname: string; - email: string; - firstname: string; - lastname: string; - title: string; - abstract: string; - startTime?: Date; - endTime?: Date; - instrumentIds: string[]; - MeasurementPeriodList: any[]; - metadata?: Record; -}; - -export type UpdateProposalDto = { - ownerGroup?: string; - accessGroups?: string[]; - pi_email?: string; - pi_firstname?: string; - pi_lastname?: string; - email: string; - firstname?: string; - lastname?: string; - title: string; - abstract?: string; - startTime?: Date; - endTime?: Date; - instrumentIds: string[]; - MeasurementPeriodList?: any[]; - metadata?: Record; -}; - export interface Institution { id: number; name: string; diff --git a/src/queue/consumers/scicat/scicatProposal/mappers/__snapshots__/uoToScicatExperiment.mapper.spec.ts.snap b/src/queue/consumers/scicat/scicatProposal/mappers/__snapshots__/uoToScicatExperiment.mapper.spec.ts.snap new file mode 100644 index 00000000..cbe13f31 --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/mappers/__snapshots__/uoToScicatExperiment.mapper.spec.ts.snap @@ -0,0 +1,151 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`getCreateScicatExperimentDto includes visitor entries when registrations are present 1`] = ` +{ + "MeasurementPeriodList": [], + "abstract": "Experiment 158548-3 +Test Abstract", + "accessGroups": [ + "158548-3", + ], + "email": "john.doe@example.com", + "endTime": 2026-10-03T07:00:00.000Z, + "firstname": "John", + "instrumentIds": [ + "scicat-inst-1", + ], + "lastname": "Doe", + "metadata": { + "instrument_name": { + "human_name": "Instrument Name", + "value": "YMIR", + }, + "number_of_visitors": { + "human_name": "Number of Visitors", + "value": 1, + }, + "status_experiment": { + "human_name": "UOS Experiment Status", + "value": "ACTIVE", + }, + "status_proposal": { + "human_name": "UOS Proposal Status", + "value": "ALLOCATED", + }, + "visitor_1_email": { + "human_name": "Visitor 1 Email", + "value": "test@example.com", + }, + "visitor_1_firstname": { + "human_name": "Visitor 1 First Name", + "value": "testFirst", + }, + "visitor_1_lastname": { + "human_name": "Visitor 1 Last Name", + "value": "testLast", + }, + "visitor_1_orcid": { + "human_name": "Visitor 1 ORCID", + "value": "testOidcSub", + }, + }, + "ownerGroup": "testuser", + "parentProposalId": "158548", + "pi_email": "john.doe@example.com", + "pi_firstname": "John", + "pi_lastname": "Doe", + "proposalId": "158548-3", + "startTime": 2026-10-02T07:00:00.000Z, + "title": "Test Proposal - 158548-3", + "type": "Experiment", +} +`; + +exports[`getCreateScicatExperimentDto maps experiment to create DTO correctly 1`] = ` +{ + "MeasurementPeriodList": [], + "abstract": "Experiment 158548-3 +Test Abstract", + "accessGroups": [ + "158548-3", + ], + "email": "john.doe@example.com", + "endTime": 2026-10-03T07:00:00.000Z, + "firstname": "John", + "instrumentIds": [ + "scicat-inst-1", + ], + "lastname": "Doe", + "metadata": { + "instrument_name": { + "human_name": "Instrument Name", + "value": "YMIR", + }, + "number_of_visitors": { + "human_name": "Number of Visitors", + "value": 0, + }, + "status_experiment": { + "human_name": "UOS Experiment Status", + "value": "ACTIVE", + }, + "status_proposal": { + "human_name": "UOS Proposal Status", + "value": "ALLOCATED", + }, + }, + "ownerGroup": "testuser", + "parentProposalId": "158548", + "pi_email": "john.doe@example.com", + "pi_firstname": "John", + "pi_lastname": "Doe", + "proposalId": "158548-3", + "startTime": 2026-10-02T07:00:00.000Z, + "title": "Test Proposal - 158548-3", + "type": "Experiment", +} +`; + +exports[`getUpdateScicatExperimentDto maps experiment to update DTO correctly 1`] = ` +{ + "MeasurementPeriodList": [], + "abstract": "Experiment 158548-3 +Test Abstract", + "accessGroups": [ + "158548-3", + ], + "email": "john.doe@example.com", + "endTime": 2026-10-03T07:00:00.000Z, + "firstname": "John", + "instrumentIds": [ + "scicat-inst-1", + ], + "lastname": "Doe", + "metadata": { + "instrument_name": { + "human_name": "Instrument Name", + "value": "YMIR", + }, + "number_of_visitors": { + "human_name": "Number of Visitors", + "value": 0, + }, + "status_experiment": { + "human_name": "UOS Experiment Status", + "value": "ACTIVE", + }, + "status_proposal": { + "human_name": "UOS Proposal Status", + "value": "ALLOCATED", + }, + }, + "ownerGroup": "testuser", + "parentProposalId": "158548", + "pi_email": "john.doe@example.com", + "pi_firstname": "John", + "pi_lastname": "Doe", + "startTime": 2026-10-02T07:00:00.000Z, + "title": "Test Proposal - 158548-3", + "type": "Experiment", +} +`; diff --git a/src/queue/consumers/scicat/scicatProposal/mappers/__snapshots__/uoToScicatProposal.mapper.spec.ts.snap b/src/queue/consumers/scicat/scicatProposal/mappers/__snapshots__/uoToScicatProposal.mapper.spec.ts.snap new file mode 100644 index 00000000..dce6907e --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/mappers/__snapshots__/uoToScicatProposal.mapper.spec.ts.snap @@ -0,0 +1,287 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`getCreateScicatProposalDto includes co-PI, DAU and instrument metadata when present 1`] = ` +{ + "MeasurementPeriodList": [], + "abstract": "Test Abstract", + "accessGroups": [], + "email": "john.doe@example.com", + "endTime": 2025-12-31T00:00:00.000Z, + "firstname": "John", + "instrumentIds": [ + "scicat-inst-1", + ], + "lastname": "Doe", + "metadata": { + "call_id": { + "human_name": "Call Id", + "value": 1, + }, + "call_name": { + "human_name": "Call", + "value": "CALL-2025", + }, + "co_i_1_affiliation": { + "human_name": "CoI 1 Affiliation", + "value": "ESS", + }, + "co_i_1_email": { + "human_name": "CoI 1 Email", + "value": "jane@example.com", + }, + "co_i_1_firstname": { + "human_name": "CoI 1 First Name", + "value": "Jane", + }, + "co_i_1_lastname": { + "human_name": "CoI 1 Last Name", + "value": "Smith", + }, + "co_i_1_orcid": { + "human_name": "CoI 1 ORCID", + "value": "0000-0002-3456-7890", + }, + "dau_1_email": { + "human_name": "Data Access User 1 Email", + "value": "bob@example.com", + }, + "dau_1_firstname": { + "human_name": "Data Access User 1 First Name", + "value": "Bob", + }, + "dau_1_lastname": { + "human_name": "Data Access User 1 Last Name", + "value": "Jones", + }, + "dau_1_orcid": { + "human_name": "Data Access User 1 ORCID", + "value": "0000-0003-4567-8901", + }, + "dau_2_email": { + "human_name": "Data Access User 2 Email", + "value": "alice@example.com", + }, + "dau_2_firstname": { + "human_name": "Data Access User 2 First Name", + "value": "Alice", + }, + "dau_2_lastname": { + "human_name": "Data Access User 2 Last Name", + "value": "Johnson", + }, + "dau_2_orcid": { + "human_name": "Data Access User 2 ORCID", + "value": "0000-0004-5678-9012", + }, + "end_call": { + "human_name": "Call End Date", + "value": "2025-12-31T00:00:00.000Z", + }, + "instrument_1_contact_firstname": { + "human_name": "Instrument 1 Contact First Name", + "value": "Fredrik", + }, + "instrument_1_contact_lastname": { + "human_name": "Instrument 1 Contact Last Name", + "value": "Bolmsten", + }, + "instrument_1_id": { + "human_name": "Instrument 1 Id", + "value": 1, + }, + "instrument_1_name": { + "human_name": "Instrument 1 Name", + "value": "YMIR", + }, + "number_of_co_is": { + "human_name": "Number of CoIs", + "value": 1, + }, + "number_of_dau": { + "human_name": "Number of Data Access Users", + "value": 2, + }, + "number_of_instruments": { + "human_name": "Number of Instruments", + "value": 1, + }, + "pi_email": { + "human_name": "PI Email", + "value": "john.doe@example.com", + }, + "pi_firstname": { + "human_name": "PI First Name", + "value": "John", + }, + "pi_lastname": { + "human_name": "PI Last Name", + "value": "Doe", + }, + "pi_orcid": { + "human_name": "PI ORCID", + "value": "0000-0001-2345-6789", + }, + "start_call": { + "human_name": "Call Start Date", + "value": "2025-01-01T00:00:00.000Z", + }, + "status": { + "human_name": "UOS Proposal Status", + "value": "ALLOCATED", + }, + }, + "ownerGroup": "158548", + "pi_email": "john.doe@example.com", + "pi_firstname": "John", + "pi_lastname": "Doe", + "proposalId": "158548", + "startTime": 2025-01-01T00:00:00.000Z, + "title": "Test Proposal", + "type": "Proposal", +} +`; + +exports[`getCreateScicatProposalDto maps proposal to create DTO correctly 1`] = ` +{ + "MeasurementPeriodList": [], + "abstract": "Test Abstract", + "accessGroups": [], + "email": "john.doe@example.com", + "endTime": 2025-12-31T00:00:00.000Z, + "firstname": "John", + "instrumentIds": [ + "scicat-inst-1", + ], + "lastname": "Doe", + "metadata": { + "call_id": { + "human_name": "Call Id", + "value": 1, + }, + "call_name": { + "human_name": "Call", + "value": "CALL-2025", + }, + "end_call": { + "human_name": "Call End Date", + "value": "2025-12-31T00:00:00.000Z", + }, + "number_of_co_is": { + "human_name": "Number of CoIs", + "value": 0, + }, + "number_of_dau": { + "human_name": "Number of Data Access Users", + "value": 0, + }, + "number_of_instruments": { + "human_name": "Number of Instruments", + "value": 0, + }, + "pi_email": { + "human_name": "PI Email", + "value": "john.doe@example.com", + }, + "pi_firstname": { + "human_name": "PI First Name", + "value": "John", + }, + "pi_lastname": { + "human_name": "PI Last Name", + "value": "Doe", + }, + "pi_orcid": { + "human_name": "PI ORCID", + "value": "0000-0001-2345-6789", + }, + "start_call": { + "human_name": "Call Start Date", + "value": "2025-01-01T00:00:00.000Z", + }, + "status": { + "human_name": "UOS Proposal Status", + "value": "ALLOCATED", + }, + }, + "ownerGroup": "158548", + "pi_email": "john.doe@example.com", + "pi_firstname": "John", + "pi_lastname": "Doe", + "proposalId": "158548", + "startTime": 2025-01-01T00:00:00.000Z, + "title": "Test Proposal", + "type": "Proposal", +} +`; + +exports[`getUpdateScicatProposalDto maps proposal to update DTO correctly 1`] = ` +{ + "MeasurementPeriodList": [], + "abstract": "Test Abstract", + "accessGroups": [], + "email": "john.doe@example.com", + "endTime": 2025-12-31T00:00:00.000Z, + "firstname": "John", + "instrumentIds": [ + "scicat-inst-1", + ], + "lastname": "Doe", + "metadata": { + "call_id": { + "human_name": "Call Id", + "value": 1, + }, + "call_name": { + "human_name": "Call", + "value": "CALL-2025", + }, + "end_call": { + "human_name": "Call End Date", + "value": "2025-12-31T00:00:00.000Z", + }, + "number_of_co_is": { + "human_name": "Number of CoIs", + "value": 0, + }, + "number_of_dau": { + "human_name": "Number of Data Access Users", + "value": 0, + }, + "number_of_instruments": { + "human_name": "Number of Instruments", + "value": 0, + }, + "pi_email": { + "human_name": "PI Email", + "value": "john.doe@example.com", + }, + "pi_firstname": { + "human_name": "PI First Name", + "value": "John", + }, + "pi_lastname": { + "human_name": "PI Last Name", + "value": "Doe", + }, + "pi_orcid": { + "human_name": "PI ORCID", + "value": "0000-0001-2345-6789", + }, + "start_call": { + "human_name": "Call Start Date", + "value": "2025-01-01T00:00:00.000Z", + }, + "status": { + "human_name": "UOS Proposal Status", + "value": "ALLOCATED", + }, + }, + "ownerGroup": "158548", + "pi_email": "john.doe@example.com", + "pi_firstname": "John", + "pi_lastname": "Doe", + "startTime": 2025-01-01T00:00:00.000Z, + "title": "Test Proposal", + "type": "Proposal", +} +`; diff --git a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.spec.ts b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.spec.ts new file mode 100644 index 00000000..f50ed7fe --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.spec.ts @@ -0,0 +1,112 @@ +jest.mock('../utils/scicatApi', () => ({ + scicatApi: { + serviceUsername: 'testuser', + }, +})); + +import { + getCreateScicatExperimentDto, + getUpdateScicatExperimentDto, +} from './uoToScicatExperiment.mapper'; +import { UOExperimentDto } from '../../../../../services/userOfficeApi/type/uoExperiment.type'; + +const createBaseUoExperiment = ( + overrides: Partial = {} +): UOExperimentDto => ({ + experimentId: '158548-3', + startsAt: '2026-10-02T07:00:00.000Z', + endsAt: '2026-10-03T07:00:00.000Z', + status: 'ACTIVE', + instrument: { + id: 1, + name: 'YMIR', + shortCode: 'ymir', + }, + proposal: { + proposalId: '158548', + title: 'Test Proposal', + abstract: 'Test Abstract', + status: { id: 1, shortCode: 'ALLOCATED', name: 'ALLOCATED' }, + proposer: { + id: 1, + firstname: 'John', + lastname: 'Doe', + email: 'john.doe@example.com', + oidcSub: '0000-0001-2345-6789', + institution: 'ESS', + }, + }, + visit: null, + ...overrides, +}); + +const instrumentIds = ['scicat-inst-1']; + +describe('getCreateScicatExperimentDto', () => { + it('maps experiment to create DTO correctly', () => { + const dto = getCreateScicatExperimentDto( + createBaseUoExperiment(), + instrumentIds + ); + + expect(dto).toMatchSnapshot(); + }); + + it('includes visitor entries when registrations are present', () => { + const dto = getCreateScicatExperimentDto( + createBaseUoExperiment({ + visit: { + registrations: [ + { + status: 'DRAFTED', + user: { + id: 7, + firstname: 'testFirst', + lastname: 'testLast', + email: 'test@example.com', + oidcSub: 'testOidcSub', + institution: 'ESS', + }, + }, + ], + }, + }), + instrumentIds + ); + + expect(dto).toMatchSnapshot(); + }); + + it('skips visitor entries when user is null', () => { + const dto = getCreateScicatExperimentDto( + createBaseUoExperiment({ + visit: { + registrations: [{ status: 'DRAFTED', user: null }], + }, + }), + instrumentIds + ); + + expect(dto.metadata?.['visitor_1_firstname']).toBeUndefined(); + }); +}); + +describe('getUpdateScicatExperimentDto', () => { + it('maps experiment to update DTO correctly', () => { + const dto = getUpdateScicatExperimentDto( + createBaseUoExperiment(), + instrumentIds + ); + + expect(dto).toMatchSnapshot(); + }); + + it('should not include proposalId', () => { + const dto = getUpdateScicatExperimentDto( + createBaseUoExperiment(), + instrumentIds + ); + + expect((dto as any).proposalId).toBeUndefined(); + }); +}); diff --git a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts new file mode 100644 index 00000000..6eb955db --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts @@ -0,0 +1,216 @@ +import { createHash } from 'crypto'; + +import { + UOExperimentDto, + UOExperimentSafetySample, +} from '../../../../../services/userOfficeApi/type/uoExperiment.type'; +import { + CreateScicatProposalDto, + CreateScicatSampleDto, + MdEntry, + MdEntryValue, + UpdateScicatProposalDto, + UpdateScicatSampleDto, +} from '../type/scicatProposal.type'; +import { metadataEntry } from '../utils/common'; +import { scicatApi } from '../utils/scicatApi'; + +// ────────────────────────────────────────────────────────────────────────────── +// ── Experiment ──────────────────────────────────────────────────────────────── +// ────────────────────────────────────────────────────────────────────────────── +const buildMetadata = (experiment: UOExperimentDto): MdEntry => { + const { proposal, instrument, visit } = experiment; + const registrations = visit?.registrations ?? []; + + const rows: [string, MdEntryValue][] = [ + metadataEntry( + 'status_proposal', + 'UOS Proposal Status', + proposal.status.name + ), + metadataEntry( + 'status_experiment', + 'UOS Experiment Status', + experiment.status + ), + metadataEntry('instrument_name', 'Instrument Name', instrument.name), + metadataEntry( + 'number_of_visitors', + 'Number of Visitors', + registrations.length + ), + ]; + + registrations.forEach((registration, index) => { + if (!registration.user) return; + const i = index + 1; + rows.push( + metadataEntry( + `visitor_${i}_firstname`, + `Visitor ${i} First Name`, + registration.user.firstname + ), + metadataEntry( + `visitor_${i}_lastname`, + `Visitor ${i} Last Name`, + registration.user.lastname + ), + metadataEntry( + `visitor_${i}_email`, + `Visitor ${i} Email`, + registration.user.email + ), + metadataEntry( + `visitor_${i}_orcid`, + `Visitor ${i} ORCID`, + registration.user.oidcSub + ) + ); + }); + + return Object.fromEntries(rows); +}; + +export const getCreateScicatExperimentDto = ( + experiment: UOExperimentDto, + instrumentIds: string[] +): CreateScicatProposalDto => { + const { proposal, experimentId } = experiment; + const { proposer } = proposal; + + return { + type: 'Experiment', + proposalId: experimentId, + parentProposalId: proposal.proposalId, + title: `${proposal.title} - ${experimentId}`, + abstract: `Experiment ${experimentId}\n${proposal.abstract}`, + firstname: proposer.firstname, + lastname: proposer.lastname, + email: proposer.email, + pi_firstname: proposer.firstname, + pi_lastname: proposer.lastname, + pi_email: proposer.email, + instrumentIds, + ownerGroup: scicatApi.serviceUsername || '', + accessGroups: [experimentId], + startTime: new Date(experiment.startsAt), + endTime: new Date(experiment.endsAt), + MeasurementPeriodList: [], + metadata: buildMetadata(experiment), + }; +}; + +export const getUpdateScicatExperimentDto = ( + experiment: UOExperimentDto, + instrumentIds: string[] +): UpdateScicatProposalDto => { + const { proposal, experimentId } = experiment; + const { proposer } = proposal; + + return { + type: 'Experiment', + parentProposalId: proposal.proposalId, + title: `${proposal.title} - ${experimentId}`, + abstract: `Experiment ${experimentId}\n${proposal.abstract}`, + firstname: proposer.firstname, + lastname: proposer.lastname, + email: proposer.email, + pi_firstname: proposer.firstname, + pi_lastname: proposer.lastname, + pi_email: proposer.email, + instrumentIds, + ownerGroup: scicatApi.serviceUsername, + accessGroups: [experimentId], + startTime: new Date(experiment.startsAt), + endTime: new Date(experiment.endsAt), + MeasurementPeriodList: [], + metadata: buildMetadata(experiment), + }; +}; + +// ────────────────────────────────────────────────────────────────────────────── +// ── Sample ──────────────────────────────────────────────────────────────────── +// ────────────────────────────────────────────────────────────────────────────── + +const buildSampleHash = (dto: CreateScicatSampleDto): string => { + const hashableData = { + accessGroups: dto.accessGroups, + sampleName: dto.sampleName, + isPublished: dto.isPublished, + ownerGroup: dto.ownerGroup, + proposalId: dto.proposalId, + sampleCharacteristics: dto.sampleCharacteristics, + }; + + return createHash('md5').update(JSON.stringify(hashableData)).digest('hex'); +}; + +const buildSampleCharacteristics = ( + sample: UOExperimentSafetySample, + experiment: UOExperimentDto +): Record => { + const fields = sample.questionary.steps.flatMap((step) => step.fields); + const sampleLookup = `${experiment.experimentId}-${sample.sampleId}`; + + const rows: [string, MdEntryValue][] = [ + metadataEntry('uos_sample_id', 'UOS Sample ID', sample.sampleId), + metadataEntry('uos_sample_lookup', 'UOS Sample Lookup', sampleLookup), + metadataEntry( + 'questionary_length', + 'Number of questions in Questionary', + fields.length + ), + ...fields.map((field) => + metadataEntry( + field.question.id, + field.question.question, + JSON.stringify(field.value) + ) + ), + ]; + + return Object.fromEntries(rows); +}; + +export const getCreateScicatSampleDto = ( + sample: UOExperimentSafetySample, + experiment: UOExperimentDto +): CreateScicatSampleDto => { + const sampleCharacteristics = buildSampleCharacteristics(sample, experiment); + const dto = { + ownerGroup: scicatApi.serviceUsername || '', + type: 'Sample Information', + accessGroups: [experiment.experimentId], + proposalId: experiment.experimentId, + sampleName: sample.sample.title, + isPublished: false, + sampleCharacteristics, + }; + dto.sampleCharacteristics['sample_hash'] = { + human_name: 'Sample Hash', + value: buildSampleHash(dto), + }; + + return dto; +}; + +export const getUpdateScicatSampleDto = ( + sample: UOExperimentSafetySample, + experiment: UOExperimentDto +): UpdateScicatSampleDto => { + const sampleCharacteristics = buildSampleCharacteristics(sample, experiment); + const dto = { + ownerGroup: scicatApi.serviceUsername || '', + accessGroups: [experiment.experimentId], + proposalId: experiment.experimentId, + sampleName: sample.sample.title, + isPublished: false, + sampleCharacteristics, + }; + dto.sampleCharacteristics['sample_hash'] = { + human_name: 'Sample Hash', + value: buildSampleHash(dto), + }; + + return dto; +}; diff --git a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatProposal.mapper.spec.ts b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatProposal.mapper.spec.ts new file mode 100644 index 00000000..9800bba8 --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatProposal.mapper.spec.ts @@ -0,0 +1,110 @@ +import { + getCreateScicatProposalDto, + getUpdateScicatProposalDto, +} from './uoToScicatProposal.mapper'; +import { UOProposalDto } from '../../../../../services/userOfficeApi/type/uoProposal.type'; + +const createBaseUoProposal = ( + overrides: Partial = {} +): UOProposalDto => ({ + proposalId: '158548', + title: 'Test Proposal', + abstract: 'Test Abstract', + status: { id: 1, shortCode: 'ALLOCATED', name: 'ALLOCATED' }, + proposer: { + id: 1, + firstname: 'John', + lastname: 'Doe', + email: 'john.doe@example.com', + oidcSub: '0000-0001-2345-6789', + institution: 'ESS', + }, + users: [], + dataAccessUsers: [], + instruments: [], + call: { + id: 1, + shortCode: 'CALL-2025', + isActive: true, + startCall: '2025-01-01T00:00:00.000Z', + endCall: '2025-12-31T00:00:00.000Z', + }, + ...overrides, +}); + +const instrumentIds = ['scicat-inst-1']; + +describe('getCreateScicatProposalDto', () => { + it('maps proposal to create DTO correctly', () => { + expect( + getCreateScicatProposalDto(createBaseUoProposal(), instrumentIds) + ).toMatchSnapshot(); + }); + + it('includes co-PI, DAU and instrument metadata when present', () => { + expect( + getCreateScicatProposalDto( + createBaseUoProposal({ + users: [ + { + id: 2, + firstname: 'Jane', + lastname: 'Smith', + email: 'jane@example.com', + oidcSub: '0000-0002-3456-7890', + institution: 'ESS', + }, + ], + dataAccessUsers: [ + { + id: 3, + firstname: 'Bob', + lastname: 'Jones', + email: 'bob@example.com', + oidcSub: '0000-0003-4567-8901', + institution: 'ESS', + }, + { + id: 4, + firstname: 'Alice', + lastname: 'Johnson', + email: 'alice@example.com', + oidcSub: '0000-0004-5678-9012', + institution: 'ESS', + }, + ], + instruments: [ + { + id: 1, + name: 'YMIR', + shortCode: 'ymir', + instrumentContact: { + id: 7, + firstname: 'Fredrik', + lastname: 'Bolmsten', + }, + }, + ], + }), + instrumentIds + ) + ).toMatchSnapshot(); + }); +}); + +describe('getUpdateScicatProposalDto', () => { + it('maps proposal to update DTO correctly', () => { + expect( + getUpdateScicatProposalDto(createBaseUoProposal(), instrumentIds) + ).toMatchSnapshot(); + }); + + it('should not include proposalId', () => { + const dto = getUpdateScicatProposalDto( + createBaseUoProposal(), + instrumentIds + ); + + expect((dto as any).proposalId).toBeUndefined(); + }); +}); diff --git a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatProposal.mapper.ts b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatProposal.mapper.ts new file mode 100644 index 00000000..186d818f --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatProposal.mapper.ts @@ -0,0 +1,160 @@ +import { UOProposalDto } from '../../../../../services/userOfficeApi/type/uoProposal.type'; +import { + CreateScicatProposalDto, + MdEntry, + MdEntryValue, + UpdateScicatProposalDto, +} from '../type/scicatProposal.type'; +import { metadataEntry } from '../utils/common'; + +const buildMetadata = (proposal: UOProposalDto): MdEntry => { + const { + proposer, + users = [], + dataAccessUsers = [], + instruments = [], + call, + status, + } = proposal; + + const rows: [string, MdEntryValue][] = [ + metadataEntry('status', 'UOS Proposal Status', status.name), + metadataEntry('pi_firstname', 'PI First Name', proposer.firstname), + metadataEntry('pi_lastname', 'PI Last Name', proposer.lastname), + metadataEntry('pi_email', 'PI Email', proposer.email), + metadataEntry('pi_orcid', 'PI ORCID', proposer.oidcSub), + metadataEntry('number_of_co_is', 'Number of CoIs', users.length), + metadataEntry( + 'number_of_dau', + 'Number of Data Access Users', + dataAccessUsers.length + ), + metadataEntry('call_name', 'Call', call.shortCode), + metadataEntry('call_id', 'Call Id', call.id), + metadataEntry('start_call', 'Call Start Date', call.startCall), + metadataEntry('end_call', 'Call End Date', call.endCall), + metadataEntry( + 'number_of_instruments', + 'Number of Instruments', + instruments.length + ), + ]; + + // Co-PIs + users.forEach((user, index) => { + const i = index + 1; + rows.push( + metadataEntry( + `co_i_${i}_firstname`, + `CoI ${i} First Name`, + user.firstname + ), + metadataEntry(`co_i_${i}_lastname`, `CoI ${i} Last Name`, user.lastname), + metadataEntry(`co_i_${i}_email`, `CoI ${i} Email`, user.email), + metadataEntry(`co_i_${i}_orcid`, `CoI ${i} ORCID`, user.oidcSub), + metadataEntry( + `co_i_${i}_affiliation`, + `CoI ${i} Affiliation`, + user.institution + ) + ); + }); + + // Data Access Users + dataAccessUsers.forEach((dau, index) => { + const i = index + 1; + rows.push( + metadataEntry( + `dau_${i}_firstname`, + `Data Access User ${i} First Name`, + dau.firstname + ), + metadataEntry( + `dau_${i}_lastname`, + `Data Access User ${i} Last Name`, + dau.lastname + ), + metadataEntry(`dau_${i}_email`, `Data Access User ${i} Email`, dau.email), + metadataEntry( + `dau_${i}_orcid`, + `Data Access User ${i} ORCID`, + dau.oidcSub + ) + ); + }); + + // Instruments + instruments.forEach((inst, index) => { + const i = index + 1; + const ic = inst.instrumentContact; + rows.push( + metadataEntry(`instrument_${i}_name`, `Instrument ${i} Name`, inst.name), + metadataEntry(`instrument_${i}_id`, `Instrument ${i} Id`, inst.id), + metadataEntry( + `instrument_${i}_contact_firstname`, + `Instrument ${i} Contact First Name`, + ic?.firstname || null + ), + metadataEntry( + `instrument_${i}_contact_lastname`, + `Instrument ${i} Contact Last Name`, + ic?.lastname || null + ) + ); + }); + + return Object.fromEntries(rows); +}; + +export const getCreateScicatProposalDto = ( + proposal: UOProposalDto, + instrumentIds: string[] +): CreateScicatProposalDto => { + const { proposer } = proposal; + + return { + type: 'Proposal', + proposalId: proposal.proposalId, + title: proposal.title, + abstract: proposal.abstract, + firstname: proposer.firstname, + lastname: proposer.lastname, + email: proposer.email, + pi_firstname: proposer.firstname, + pi_lastname: proposer.lastname, + pi_email: proposer.email, + instrumentIds, + ownerGroup: proposal.proposalId, + accessGroups: [], + startTime: new Date(proposal.call.startCall), + endTime: new Date(proposal.call.endCall), + MeasurementPeriodList: [], + metadata: buildMetadata(proposal), + }; +}; + +export const getUpdateScicatProposalDto = ( + proposal: UOProposalDto, + instrumentIds: string[] +): UpdateScicatProposalDto => { + const { proposer } = proposal; + + return { + type: 'Proposal', + title: proposal.title, + abstract: proposal.abstract, + firstname: proposer.firstname, + lastname: proposer.lastname, + email: proposer.email, + pi_firstname: proposer.firstname, + pi_lastname: proposer.lastname, + pi_email: proposer.email, + instrumentIds, + ownerGroup: proposal.proposalId, + accessGroups: [], + startTime: new Date(proposal.call.startCall), + endTime: new Date(proposal.call.endCall), + MeasurementPeriodList: [], + metadata: buildMetadata(proposal), + }; +}; diff --git a/src/queue/consumers/scicat/scicatProposal/type/scicatProposal.type.ts b/src/queue/consumers/scicat/scicatProposal/type/scicatProposal.type.ts new file mode 100644 index 00000000..612e58ec --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/type/scicatProposal.type.ts @@ -0,0 +1,68 @@ +export interface MdEntryValue { + human_name?: string; + value: string | number | boolean | null | undefined; +} + +export interface MdEntry { + [key: string]: MdEntryValue; +} + +export type CreateScicatProposalDto = { + ownerGroup: string; + accessGroups: string[]; + proposalId: string; + parentProposalId?: string; + pi_email: string; + pi_firstname: string; + pi_lastname: string; + email: string; + type: 'Proposal' | 'Experiment'; + firstname: string; + lastname: string; + title: string; + abstract: string; + startTime?: Date; + endTime?: Date; + instrumentIds: string[]; + MeasurementPeriodList: any[]; + metadata?: Record; +}; + +export type UpdateScicatProposalDto = { + ownerGroup?: string; + accessGroups?: string[]; + parentProposalId?: string; + pi_email?: string; + pi_firstname?: string; + pi_lastname?: string; + email: string; + firstname?: string; + lastname?: string; + type: 'Proposal' | 'Experiment'; + title: string; + abstract?: string; + startTime?: Date; + endTime?: Date; + instrumentIds: string[]; + MeasurementPeriodList?: any[]; + metadata?: Record; +}; + +export type CreateScicatSampleDto = { + proposalId: string; + sampleName: string; + ownerGroup: string; + accessGroups?: string[]; + isPublished?: boolean; + sampleCharacteristics?: Record; +}; + +export type UpdateScicatSampleDto = { + sampleId?: string; + sampleName?: string; + proposalId?: string; + ownerGroup?: string; + accessGroups?: string[]; + isPublished?: boolean; + sampleCharacteristics?: Record; +}; diff --git a/src/queue/consumers/scicat/scicatProposal/utils/common.ts b/src/queue/consumers/scicat/scicatProposal/utils/common.ts new file mode 100644 index 00000000..89b755e6 --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/utils/common.ts @@ -0,0 +1,10 @@ +import { MdEntryValue } from '../type/scicatProposal.type'; + +export const metadataEntry = ( + key: string, + human_name: string, + value: string | number | boolean | null | undefined +): [string, MdEntryValue] => [ + key, + human_name ? { human_name, value } : { value }, +]; diff --git a/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.spec.ts b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.spec.ts new file mode 100644 index 00000000..4f7edade --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.spec.ts @@ -0,0 +1,25 @@ +describe('ScicatApi', () => { + it('should initialize with correct serviceUsername', () => { + process.env.SCICAT_BASE_URL = 'http://localhost:3000'; + process.env.SCICAT_JWT = + 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InRlc3R1c2VyIn0.signature'; + + jest.isolateModules(() => { + const { scicatApi } = require('./scicatApi'); + expect(scicatApi.serviceUsername).toBe('testuser'); + }); + + delete process.env.SCICAT_BASE_URL; + delete process.env.SCICAT_JWT; + }); + + it('should throw if SCICAT_JWT is not defined', () => { + process.env.SCICAT_BASE_URL = 'http://localhost:3000'; + + jest.isolateModules(() => { + expect(() => require('./scicatApi')).toThrow('SCICAT_JWT is not defined'); + }); + + delete process.env.SCICAT_BASE_URL; + }); +}); diff --git a/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts new file mode 100644 index 00000000..ec4e6a01 --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts @@ -0,0 +1,351 @@ +import { logger } from '@user-office-software/duo-logger'; + +import { InstrumentDto } from '../../../../../models/ProposalMessage'; +import { UOExperimentDto } from '../../../../../services/userOfficeApi/type/uoExperiment.type'; +import { + UOInstrument, + UOProposalDto, +} from '../../../../../services/userOfficeApi/type/uoProposal.type'; +import { + getCreateScicatExperimentDto, + getUpdateScicatExperimentDto, +} from '../mappers/uoToScicatExperiment.mapper'; +import { + getCreateScicatProposalDto, + getUpdateScicatProposalDto, +} from '../mappers/uoToScicatProposal.mapper'; +import { + CreateScicatProposalDto, + CreateScicatSampleDto, + UpdateScicatProposalDto, + UpdateScicatSampleDto, +} from '../type/scicatProposal.type'; + +class ScicatApi { + readonly baseUrl = process.env.SCICAT_BASE_URL; + readonly scicatToken = process.env.SCICAT_JWT; + readonly serviceUsername: string; + + constructor() { + if (!this.baseUrl) { + throw new Error('SCICAT_BASE_URL is not defined'); + } + + if (!this.scicatToken) { + throw new Error('SCICAT_JWT is not defined'); + } + + this.serviceUsername = this.getServiceUsername(this.scicatToken); + + logger.logInfo('ScicatApi initialized', { + baseUrl: this.baseUrl, + serviceUsername: this.serviceUsername, + }); + } + + private getServiceUsername(token: string): string { + try { + const payload = JSON.parse( + Buffer.from(token.split('.')[1], 'base64url').toString() + ); + if (!payload.username) { + throw new Error('Username not found in token payload'); + } + + return payload.username; + } catch { + throw new Error('Failed to decode JWT token'); + } + } + + async request( + url: string, + config: RequestInit + ): Promise { + const response = await fetch(url, config); + + const text = await response.text(); + + if (!response.ok) { + throw new Error(text || `HTTP error: ${response.status}`); + } + + if (!text) return undefined as TResponse; + + return JSON.parse(text) as TResponse; + } + + async getInstrumentIds( + instruments: UOInstrument | UOInstrument[] + ): Promise { + const sciCatAccessToken = this.scicatToken; + const instrumentArray = Array.isArray(instruments) + ? instruments + : [instruments]; + const instrumentNames = instrumentArray.map((inst) => inst.shortCode); + + const instrumentIds = []; + + for (const name of instrumentNames) { + const instrumentNameLowerCase = name.toLowerCase(); + + const filterString = JSON.stringify({ + where: { name: { ilike: instrumentNameLowerCase } }, + }); + + const url = `${this.baseUrl}/Instruments?filter=${encodeURIComponent(filterString)}`; + + try { + const res = await this.request(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${sciCatAccessToken}`, + }, + }); + if (res[0].pid) { + instrumentIds.push(res[0].pid); + } + } catch (error) { + logger.logError( + `Error fetching instrument ID from scicat for ${name}`, + { + error, + } + ); + } + } + + return instrumentIds; + } + + async checkProposalExists(proposalId: string): Promise { + const url = `${this.baseUrl}/Proposals/${proposalId}`; + const sciCatAccessToken = this.scicatToken; + const response = await this.request(url, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${sciCatAccessToken}`, + }, + }).catch((error) => { + try { + const parsedError = JSON.parse(error.message); + if (parsedError.statusCode === 404) { + return false; + } + } catch (reason) { + logger.logError('Error parsing error message', { + error, + reason, + }); + } + throw error; + }); + + return Boolean(response); + } + + async findSampleByLookup( + sampleLookup: string + ): Promise { + const sciCatAccessToken = this.scicatToken; + const filter = JSON.stringify({ + where: { + 'sampleCharacteristics.uos_sample_lookup.value': sampleLookup, + }, + }); + + const url = `${this.baseUrl}/Samples/findOne?filter=${encodeURIComponent(filter)}`; + + const response = await this.request(url, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${sciCatAccessToken}`, + }, + }).catch((error) => { + try { + const parsedError = JSON.parse(error.message); + if (parsedError.statusCode === 404) return null; + } catch (reason) { + logger.logError('Error parsing error message', { error, reason }); + } + throw error; + }); + + return response ?? null; + } + + async createProposal(UOProposal: UOProposalDto) { + const url = `${this.baseUrl}/Proposals`; + const sciCatAccessToken = this.scicatToken; + // RabbitMQ message only provides shortCodes (instrument names). + // To persist proposals with proper references, we resolve those shortCodes to + // actual Instrument IDs from SciCat and store the instrumentIds in the record. + const scicatInstrumentIds = await this.getInstrumentIds( + UOProposal.instruments + ); + const createProposalDto = getCreateScicatProposalDto( + UOProposal, + scicatInstrumentIds + ); + + const createProposalResponse = await this.request( + url, + { + method: 'POST', + body: JSON.stringify(createProposalDto), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${sciCatAccessToken}`, + }, + } + ); + + logger.logInfo('Proposal created in SciCat', { + url, + proposalId: createProposalDto.proposalId, + response: createProposalResponse, + }); + } + + async updateProposal(UOProposal: UOProposalDto) { + const url = `${this.baseUrl}/Proposals/${UOProposal.proposalId}`; + const sciCatAccessToken = this.scicatToken; + // RabbitMQ message only provides shortCodes (instrument names). + // To persist proposals with proper references, we resolve those shortCodes to + // actual Instrument IDs from SciCat and store the instrumentIds in the record. + const scicatInstrumentIds = await this.getInstrumentIds( + UOProposal.instruments + ); + const updateProposalDto = getUpdateScicatProposalDto( + UOProposal, + scicatInstrumentIds + ); + + const updateProposalResponse = await this.request( + url, + { + method: 'PATCH', + body: JSON.stringify(updateProposalDto), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${sciCatAccessToken}`, + }, + } + ); + + logger.logInfo('Proposal updated in SciCat', { + url, + proposalId: UOProposal.proposalId, + response: updateProposalResponse, + }); + } + + async createExperiment(UOExperiment: UOExperimentDto) { + const url = `${this.baseUrl}/Proposals`; + const sciCatAccessToken = this.scicatToken; + // RabbitMQ message only provides shortCodes (instrument names). + // To persist proposals with proper references, we resolve those shortCodes to + // actual Instrument IDs from SciCat and store the instrumentIds in the record. + const scicatInstrumentIds = await this.getInstrumentIds( + UOExperiment.instrument + ); + const createExperimentDto = getCreateScicatExperimentDto( + UOExperiment, + scicatInstrumentIds + ); + + const createExperimentResponse = + await this.request(url, { + method: 'POST', + body: JSON.stringify(createExperimentDto), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${sciCatAccessToken}`, + }, + }); + + // NOTE: UOExperiment.experimentId = proposalId in SciCat for experiments + logger.logInfo('Experiment created in SciCat', { + url, + proposalId: UOExperiment.experimentId, + response: createExperimentResponse, + }); + } + + async updateExperiment(UOExperiment: UOExperimentDto) { + const url = `${this.baseUrl}/Proposals/${UOExperiment.experimentId}`; + const sciCatAccessToken = this.scicatToken; + // RabbitMQ message only provides shortCodes (instrument names). + // To persist proposals with proper references, we resolve those shortCodes to + // actual Instrument IDs from SciCat and store the instrumentIds in the record. + const scicatInstrumentIds = await this.getInstrumentIds( + UOExperiment.instrument + ); + const updateExperimentDto = getUpdateScicatExperimentDto( + UOExperiment, + scicatInstrumentIds + ); + + const updateExperimentResponse = + await this.request(url, { + method: 'PATCH', + body: JSON.stringify(updateExperimentDto), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${sciCatAccessToken}`, + }, + }); + + // NOTE: UOExperiment.experimentId = proposalId in SciCat for experiments + logger.logInfo('Experiment updated in SciCat', { + url, + proposalId: UOExperiment.experimentId, + response: updateExperimentResponse, + }); + } + + async createSample(dto: CreateScicatSampleDto): Promise { + const sciCatAccessToken = this.scicatToken; + const url = `${this.baseUrl}/Samples`; + + const createSampleResponse = await this.request( + url, + { + method: 'POST', + body: JSON.stringify(dto), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${sciCatAccessToken}`, + }, + } + ); + + logger.logInfo('Sample created in SciCat', { + url, + response: createSampleResponse, + }); + } + + async updateSample( + sampleId: string, + dto: UpdateScicatSampleDto + ): Promise { + const sciCatAccessToken = this.scicatToken; + + const url = `${this.baseUrl}/Samples/${sampleId}`; + + await this.request(url, { + method: 'PATCH', + body: JSON.stringify(dto), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${sciCatAccessToken}`, + }, + }); + + logger.logInfo('Sample updated in SciCat', { sampleId }); + } +} + +export const scicatApi = new ScicatApi(); diff --git a/src/queue/consumers/utils/collectUsersFromProposalMessage.spec.ts b/src/queue/consumers/utils/collectUsersFromProposalMessage.spec.ts index 0de84c4d..0617eef4 100644 --- a/src/queue/consumers/utils/collectUsersFromProposalMessage.spec.ts +++ b/src/queue/consumers/utils/collectUsersFromProposalMessage.spec.ts @@ -21,6 +21,8 @@ describe('collectUsersFromProposalMessage', () => { callId: 2, submitted: true, members: [], + dataAccessUsers: [], + visitors: [], ...overrides, }); diff --git a/src/queue/consumers/utils/hasTriggeringStatus.ts b/src/queue/consumers/utils/hasTriggeringStatus.ts index 49c31de3..729c3147 100644 --- a/src/queue/consumers/utils/hasTriggeringStatus.ts +++ b/src/queue/consumers/utils/hasTriggeringStatus.ts @@ -1,4 +1,4 @@ -export const hasTriggeringStatus = ( +export const hasTriggeringProposalStatus = ( message: any, statuses: string[] | undefined ) => { @@ -13,3 +13,18 @@ export const hasTriggeringStatus = ( return true; }; + +export const hasTriggringExperimentStatus = ( + message: any, + statuses: string[] | undefined +) => { + if (!message.status || !statuses) { + return false; + } + + if (statuses.indexOf(message.status) === -1) { + return false; + } + + return true; +}; diff --git a/src/queue/consumers/utils/validateExperimentMessage.spec.ts b/src/queue/consumers/utils/validateExperimentMessage.spec.ts new file mode 100644 index 00000000..58918fa4 --- /dev/null +++ b/src/queue/consumers/utils/validateExperimentMessage.spec.ts @@ -0,0 +1,41 @@ +import { validateExperimentMessage } from './validateExperimentMessage'; + +describe('validateExperimentMessage', () => { + it('should throw error when message is not valid', () => { + expect(() => validateExperimentMessage({ message: 'message' })).toThrow(); + }); + + it('should throw when experimentId is missing', () => { + expect(() => + validateExperimentMessage({ + experimentPk: 1, + startsAt: new Date(), + endsAt: new Date(), + status: 'ALLOCATED', + }) + ).toThrow('Experiment ID is missing'); + }); + + it('should throw when experimentPk is missing', () => { + expect(() => + validateExperimentMessage({ + experimentId: 'exp-123', + startsAt: new Date(), + endsAt: new Date(), + status: 'ALLOCATED', + }) + ).toThrow('Experiment primary key is missing'); + }); + + it('should not throw when message is valid', () => { + expect(() => + validateExperimentMessage({ + experimentId: 'exp-123', + experimentPk: 1, + startsAt: new Date(), + endsAt: new Date(), + status: 'ALLOCATED', + }) + ).not.toThrow(); + }); +}); diff --git a/src/queue/consumers/utils/validateExperimentMessage.ts b/src/queue/consumers/utils/validateExperimentMessage.ts new file mode 100644 index 00000000..57caa005 --- /dev/null +++ b/src/queue/consumers/utils/validateExperimentMessage.ts @@ -0,0 +1,29 @@ +import { ExperimentMessageData } from '../../../models/ProposalMessage'; + +export type ValidExperimentMessageData = Required; + +export function validateExperimentMessage( + message: Record +): ValidExperimentMessageData { + if (!message.experimentId) { + throw new Error('Experiment ID is missing'); + } + + if (!message.experimentPk) { + throw new Error('Experiment primary key is missing'); + } + + if (!message.startsAt) { + throw new Error('Experiment start date is missing'); + } + + if (!message.endsAt) { + throw new Error('Experiment end date is missing'); + } + + if (!message.status) { + throw new Error('Experiment status is missing'); + } + + return message as unknown as ValidExperimentMessageData; +} diff --git a/src/queue/consumers/visa/consumers/syncProposalQueueConsumer.ts b/src/queue/consumers/visa/consumers/syncProposalQueueConsumer.ts index 1347bb4f..19f27087 100644 --- a/src/queue/consumers/visa/consumers/syncProposalQueueConsumer.ts +++ b/src/queue/consumers/visa/consumers/syncProposalQueueConsumer.ts @@ -4,7 +4,7 @@ import { ConsumerCallback } from '@user-office-software/duo-message-broker'; import { Event } from '../../../../models/Event'; import { ProposalMessageData } from '../../../../models/ProposalMessage'; import { QueueConsumer } from '../../QueueConsumer'; -import { hasTriggeringStatus } from '../../utils/hasTriggeringStatus'; +import { hasTriggeringProposalStatus } from '../../utils/hasTriggeringStatus'; import { hasTriggeringType } from '../../utils/hasTriggeringType'; import { validateProposalMessage } from '../../utils/validateProposalMessage'; import { syncVisaProposal } from '../consumerCallbacks/syncVisaProposal'; @@ -35,7 +35,7 @@ export class SyncProposalQueueConsumer extends QueueConsumer { message, }); - const hasStatus = hasTriggeringStatus(message, triggeringStatuses); + const hasStatus = hasTriggeringProposalStatus(message, triggeringStatuses); if (!hasStatus) { return; diff --git a/src/queue/queueHandling.ts b/src/queue/queueHandling.ts index e221b72e..c9251f11 100644 --- a/src/queue/queueHandling.ts +++ b/src/queue/queueHandling.ts @@ -1,14 +1,15 @@ import { container } from 'tsyringe'; +import { Tokens } from '../config/Tokens'; +import { str2Bool } from '../config/utils'; import { MoodleFolderCreationQueueConsumer } from './consumers/moodle/MoodleFolderCreationQueueConsumer'; import { OneIdentityIntegrationQueueConsumer } from './consumers/oneidentity/OneIdentityIntegrationQueueConsumer'; import { ChatroomCreationQueueConsumer } from './consumers/scicat/scicatProposal/consumers/ChatroomCreationQueueConsumer'; +import { ExperimentCreationQueueConsumer } from './consumers/scicat/scicatProposal/consumers/ExperimentCreationQueueConsumer'; import { FolderCreationQueueConsumer } from './consumers/scicat/scicatProposal/consumers/FolderCreationQueueConsumer'; import { ProposalCreationQueueConsumer } from './consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer'; import { SyncProposalQueueConsumer } from './consumers/visa/consumers/syncProposalQueueConsumer'; import { GetMessageBroker } from './messageBroker/getMessageBroker'; -import { Tokens } from '../config/Tokens'; -import { str2Bool } from '../config/utils'; const getMessageBroker: GetMessageBroker = container.resolve( Tokens.ProvideMessageBroker @@ -16,6 +17,7 @@ const getMessageBroker: GetMessageBroker = container.resolve( const queueConsumers = { ENABLE_SCICAT_PROPOSAL_UPSERT: ProposalCreationQueueConsumer, + ENABLE_SCICAT_EXPERIMENT_UPSERT: ExperimentCreationQueueConsumer, ENABLE_SCICHAT_ROOM_CREATION: ChatroomCreationQueueConsumer, ENABLE_PROPOSAL_FOLDERS_CREATION: FolderCreationQueueConsumer, ENABLE_MOODLE_FOLDERS_CREATION: MoodleFolderCreationQueueConsumer, diff --git a/src/services/userOfficeApi/queries/getExperiment.query.ts b/src/services/userOfficeApi/queries/getExperiment.query.ts new file mode 100644 index 00000000..68ba5612 --- /dev/null +++ b/src/services/userOfficeApi/queries/getExperiment.query.ts @@ -0,0 +1,422 @@ +export const GET_EXPERIMENT_QUERY = `query getExperiment($experimentPk: Int!) { + experiment(experimentPk: $experimentPk) { + ...experiment + proposal { + ...proposal + proposer { + ...basicUserDetails + } + } + visit { + registrations { + status + user { + ...basicUserDetails + } + } + } + experimentSafety { + ...experimentSafety + } + } +} + +fragment experiment on Experiment { + experimentPk + experimentId + startsAt + endsAt + scheduledEventId + proposalPk + status + localContactId + instrumentId + createdAt + updatedAt + instrument { + id + name + shortCode + description + managerUserId + scientists { + ...basicUserDetails + } + } +} + +fragment basicUserDetails on BasicUserDetails { + id + firstname + lastname + preferredname + institution + institutionId + created + email + country + oidcSub +} + +fragment proposal on Proposal { + primaryKey + title + abstract + statusId + status { + ...status + } + publicStatus + proposalId + finalStatus + commentForUser + commentForManagement + created + updated + callId + questionaryId + notified + submitted + managementDecisionSubmitted + fileId +} + +fragment status on Status { + id + shortCode + name + description + isDefault + entityType +} + +fragment experimentSafety on ExperimentSafety { + experimentSafetyPk + experimentPk + esiQuestionaryId + esiQuestionarySubmittedAt + createdBy + statusId + safetyReviewQuestionaryId + reviewedBy + createdAt + updatedAt + instrumentScientistDecision + instrumentScientistComment + experimentSafetyReviewerDecision + experimentSafetyReviewerComment + status { + ...status + } + samples { + experimentPk + sampleId + isEsiSubmitted + sampleEsiQuestionaryId + createdAt + updatedAt + questionary { + ...questionary + } + sample { + ...sample + } + } +} + +fragment questionary on Questionary { + questionaryId + templateId + created + steps { + ...questionaryStep + } +} + +fragment questionaryStep on QuestionaryStep { + topic { + ...topic + } + isCompleted + fields { + ...answer + } +} + +fragment topic on Topic { + title + id + templateId + sortOrder + isEnabled +} + +fragment answer on Answer { + answerId + question { + ...question + } + sortOrder + topicId + config { + ...fieldConfig + } + dependencies { + questionId + dependencyId + dependencyNaturalKey + condition { + ...fieldCondition + } + } + dependenciesOperator + value +} + +fragment question on Question { + id + question + naturalKey + dataType + categoryId + config { + ...fieldConfig + } +} + +fragment fieldConfig on FieldConfig { + ... on BooleanConfig { + small_label + required + tooltip + readPermissions + } + ... on DateConfig { + small_label + required + tooltip + readPermissions + minDate + maxDate + defaultDate + includeTime + } + ... on EmbellishmentConfig { + html + plain + omitFromPdf + readPermissions + } + ... on FileUploadConfig { + file_type + max_files + pdf_page_limit + omitFromPdf + small_label + required + tooltip + readPermissions + } + ... on IntervalConfig { + units { + ...unit + } + numberValueConstraint + small_label + required + tooltip + readPermissions + } + ... on NumberInputConfig { + units { + ...unit + } + numberValueConstraint + small_label + required + tooltip + readPermissions + } + ... on ProposalBasisConfig { + tooltip + readPermissions + } + ... on ProposalEsiBasisConfig { + tooltip + readPermissions + } + ... on SampleEsiBasisConfig { + tooltip + readPermissions + } + ... on SampleBasisConfig { + titlePlaceholder + readPermissions + } + ... on SampleDeclarationConfig { + addEntryButtonLabel + minEntries + maxEntries + templateId + esiTemplateId + templateCategory + required + small_label + readPermissions + } + ... on SubTemplateConfig { + addEntryButtonLabel + copyButtonLabel + canCopy + isMultipleCopySelect + isCompleteOnCopy + minEntries + maxEntries + templateId + templateCategory + required + small_label + readPermissions + } + ... on SelectionFromOptionsConfig { + variant + options + isMultipleSelect + small_label + required + tooltip + readPermissions + } + ... on TextInputConfig { + min + max + multiline + placeholder + small_label + required + tooltip + readPermissions + htmlQuestion + isHtmlQuestion + isCounterHidden + } + ... on ShipmentBasisConfig { + small_label + required + tooltip + readPermissions + } + ... on RichTextInputConfig { + small_label + required + tooltip + readPermissions + max + allowImages + } + ... on DynamicMultipleChoiceConfig { + small_label + required + tooltip + readPermissions + url + jsonPath + apiCallRequestHeaders { + name + value + } + isMultipleSelect + variant + } + ... on VisitBasisConfig { + small_label + required + tooltip + readPermissions + } + ... on FapReviewBasisConfig { + small_label + required + tooltip + readPermissions + minGrade + maxGrade + decimalPoints + nonNumericOptions + } + ... on TechnicalReviewBasisConfig { + small_label + required + tooltip + readPermissions + } + ... on GenericTemplateBasisConfig { + titlePlaceholder + questionLabel + readPermissions + } + ... on FeedbackBasisConfig { + small_label + required + tooltip + readPermissions + } + ... on ExperimentSafetyReviewBasisConfig { + small_label + required + tooltip + readPermissions + } + ... on InstrumentPickerConfig { + variant + small_label + required + tooltip + readPermissions + isMultipleSelect + requestTime + instruments { + id + name + } + } + ... on TechniquePickerConfig { + variant + small_label + required + tooltip + readPermissions + isMultipleSelect + techniques { + id + name + } + } +} + +fragment unit on Unit { + id + unit + quantity + symbol + siConversionFormula +} + +fragment fieldCondition on FieldCondition { + condition + params +} + +fragment sample on Sample { + id + title + creatorId + questionaryId + safetyStatus + safetyComment + isPostProposalSubmission + created + proposalPk + questionId +} +`; diff --git a/src/services/userOfficeApi/queries/getProposal.query.ts b/src/services/userOfficeApi/queries/getProposal.query.ts new file mode 100644 index 00000000..4eba4090 --- /dev/null +++ b/src/services/userOfficeApi/queries/getProposal.query.ts @@ -0,0 +1,398 @@ +export const GET_PROPOSAL_QUERY = `query getProposal($primaryKey: Int!) { + proposal(primaryKey: $primaryKey) { + ...proposal + proposer { + ...basicUserDetails + } + users { + ...basicUserDetails + } + dataAccessUsers { + ...basicUserDetails + } + experiments { + experimentPk + } + instruments { + id + name + shortCode + instrumentContact { + id + firstname + lastname + preferredname + } + scientists { + id + firstname + lastname + preferredname + } + managementTimeAllocation + multipleTechReviewsEnabled + } + call { + id + shortCode + isActive + isActiveInternal + allocationTimeUnit + referenceNumberFormat + startCall + endCall + endCallInternal + proposalWorkflowId + } + samples { + ...sample + questionary { + ...questionary + } + } + } +} + +fragment proposal on Proposal { + primaryKey + title + abstract + statusId + status { + ...status + } + publicStatus + proposalId + finalStatus + commentForUser + commentForManagement + created + updated + callId + questionaryId + notified + submitted + managementDecisionSubmitted + fileId +} + +fragment status on Status { + id + shortCode + name + description + isDefault + entityType +} + +fragment basicUserDetails on BasicUserDetails { + id + firstname + lastname + preferredname + institution + institutionId + created + email + country + oidcSub +} + +fragment questionary on Questionary { + questionaryId + templateId + created + steps { + ...questionaryStep + } +} + +fragment questionaryStep on QuestionaryStep { + topic { + ...topic + } + isCompleted + fields { + ...answer + } +} + +fragment topic on Topic { + title + id + templateId + sortOrder + isEnabled +} + +fragment answer on Answer { + answerId + question { + ...question + } + sortOrder + topicId + config { + ...fieldConfig + } + dependencies { + questionId + dependencyId + dependencyNaturalKey + condition { + ...fieldCondition + } + } + dependenciesOperator + value +} + +fragment question on Question { + id + question + naturalKey + dataType + categoryId + config { + ...fieldConfig + } +} + +fragment fieldConfig on FieldConfig { + ... on BooleanConfig { + small_label + required + tooltip + readPermissions + } + ... on DateConfig { + small_label + required + tooltip + readPermissions + minDate + maxDate + defaultDate + includeTime + } + ... on EmbellishmentConfig { + html + plain + omitFromPdf + readPermissions + } + ... on FileUploadConfig { + file_type + max_files + pdf_page_limit + omitFromPdf + small_label + required + tooltip + readPermissions + } + ... on IntervalConfig { + units { + ...unit + } + numberValueConstraint + small_label + required + tooltip + readPermissions + } + ... on NumberInputConfig { + units { + ...unit + } + numberValueConstraint + small_label + required + tooltip + readPermissions + } + ... on ProposalBasisConfig { + tooltip + readPermissions + } + ... on ProposalEsiBasisConfig { + tooltip + readPermissions + } + ... on SampleEsiBasisConfig { + tooltip + readPermissions + } + ... on SampleBasisConfig { + titlePlaceholder + readPermissions + } + ... on SampleDeclarationConfig { + addEntryButtonLabel + minEntries + maxEntries + templateId + esiTemplateId + templateCategory + required + small_label + readPermissions + } + ... on SubTemplateConfig { + addEntryButtonLabel + copyButtonLabel + canCopy + isMultipleCopySelect + isCompleteOnCopy + minEntries + maxEntries + templateId + templateCategory + required + small_label + readPermissions + } + ... on SelectionFromOptionsConfig { + variant + options + isMultipleSelect + small_label + required + tooltip + readPermissions + } + ... on TextInputConfig { + min + max + multiline + placeholder + small_label + required + tooltip + readPermissions + htmlQuestion + isHtmlQuestion + isCounterHidden + } + ... on ShipmentBasisConfig { + small_label + required + tooltip + readPermissions + } + ... on RichTextInputConfig { + small_label + required + tooltip + readPermissions + max + allowImages + } + ... on DynamicMultipleChoiceConfig { + small_label + required + tooltip + readPermissions + url + jsonPath + apiCallRequestHeaders { + name + value + } + isMultipleSelect + variant + } + ... on VisitBasisConfig { + small_label + required + tooltip + readPermissions + } + ... on FapReviewBasisConfig { + small_label + required + tooltip + readPermissions + minGrade + maxGrade + decimalPoints + nonNumericOptions + } + ... on TechnicalReviewBasisConfig { + small_label + required + tooltip + readPermissions + } + ... on GenericTemplateBasisConfig { + titlePlaceholder + questionLabel + readPermissions + } + ... on FeedbackBasisConfig { + small_label + required + tooltip + readPermissions + } + ... on ExperimentSafetyReviewBasisConfig { + small_label + required + tooltip + readPermissions + } + ... on InstrumentPickerConfig { + variant + small_label + required + tooltip + readPermissions + isMultipleSelect + requestTime + instruments { + id + name + } + } + ... on TechniquePickerConfig { + variant + small_label + required + tooltip + readPermissions + isMultipleSelect + techniques { + id + name + } + } +} + +fragment unit on Unit { + id + unit + quantity + symbol + siConversionFormula +} + +fragment fieldCondition on FieldCondition { + condition + params +} + +fragment sample on Sample { + id + title + creatorId + questionaryId + safetyStatus + safetyComment + isPostProposalSubmission + created + proposalPk + questionId + questionary { + ...questionary + } +}`; diff --git a/src/services/userOfficeApi/type/uoExperiment.type.ts b/src/services/userOfficeApi/type/uoExperiment.type.ts new file mode 100644 index 00000000..9c4e3a12 --- /dev/null +++ b/src/services/userOfficeApi/type/uoExperiment.type.ts @@ -0,0 +1,44 @@ +import { UOProposalDto, UOUser } from './uoProposal.type'; + +export interface UOExperimentSafetySample { + sampleId: number; + questionary: { + steps: { + fields: { + question: { + id: string; + question: string; + }; + value: string | number | boolean | null; + }[]; + }[]; + }; + sample: { + id: number; + title: string; + }; +} +export interface UOExperimentDto { + experimentId: string; + startsAt: string; + status: string; + endsAt: string; + instrument: { + id: number; + name: string; + shortCode: string; + }; + proposal: Pick< + UOProposalDto, + 'proposalId' | 'title' | 'abstract' | 'status' | 'proposer' + >; + visit?: { + registrations: { + status: string; + user: UOUser | null; + }[]; + } | null; + experimentSafety?: { + samples: UOExperimentSafetySample[]; + } | null; +} diff --git a/src/services/userOfficeApi/type/uoProposal.type.ts b/src/services/userOfficeApi/type/uoProposal.type.ts new file mode 100644 index 00000000..dc701d9c --- /dev/null +++ b/src/services/userOfficeApi/type/uoProposal.type.ts @@ -0,0 +1,54 @@ +export interface UOUser { + id: number; + firstname: string; + lastname: string; + preferredname?: string | null; + institution?: string; + email: string; + oidcSub?: string | null; + country?: string | null; +} + +export interface UOInstrumentContact { + id: number; + firstname: string; + lastname: string; + preferredname?: string | null; +} + +export interface UOInstrument { + id: number; + name: string; + shortCode: string; + instrumentContact?: UOInstrumentContact; + managementTimeAllocation?: number; +} + +export interface UOCall { + id: number; + shortCode: string; + isActive: boolean; + startCall: string; + endCall: string; + allocationTimeUnit?: string; + proposalWorkflowId?: number; +} + +export interface UOStatus { + id: number; + shortCode: string; + name: string; + description?: string; +} + +export interface UOProposalDto { + proposalId: string; + title: string; + abstract: string; + status: UOStatus; + proposer: UOUser; + users: UOUser[]; + dataAccessUsers: UOUser[]; + instruments: UOInstrument[]; + call: UOCall; +} diff --git a/src/services/userOfficeApi/uoApi.spec.ts b/src/services/userOfficeApi/uoApi.spec.ts new file mode 100644 index 00000000..5dc5bd94 --- /dev/null +++ b/src/services/userOfficeApi/uoApi.spec.ts @@ -0,0 +1,78 @@ +import { fetchUoProposal, fetchUoExperiment } from './uoApi'; + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +const mockProposal = { + proposalId: '158548', + title: 'Test Proposal', +}; + +const mockExperiment = { + experimentId: '158548-3', +}; + +beforeEach(() => { + jest.clearAllMocks(); + process.env.USER_OFFICE_GRAPHQL_URL = 'http://localhost:8080/graphql'; + process.env.USER_OFFICE_JWT = 'test-token'; +}); + +describe('fetchUoProposal', () => { + it('returns proposal data on success', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { proposal: mockProposal } }), + }); + + const result = await fetchUoProposal(1); + + expect(result).toEqual(mockProposal); + }); + + it('throws when response is not ok', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + text: async () => 'Internal Server Error', + }); + + await expect(fetchUoProposal(1)).rejects.toThrow( + 'UOS GraphQL request failed: Internal Server Error' + ); + }); + + it('throws when response contains GraphQL errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + errors: [{ message: 'Not found' }], + }), + }); + + await expect(fetchUoProposal(1)).rejects.toThrow('UOS GraphQL errors'); + }); +}); + +describe('fetchUoExperiment', () => { + it('returns experiment data on success', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { experiment: mockExperiment } }), + }); + + const result = await fetchUoExperiment(311); + + expect(result).toEqual(mockExperiment); + }); + + it('throws when response is not ok', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + text: async () => 'Internal Server Error', + }); + + await expect(fetchUoExperiment(311)).rejects.toThrow( + 'UOS GraphQL request failed: Internal Server Error' + ); + }); +}); diff --git a/src/services/userOfficeApi/uoApi.ts b/src/services/userOfficeApi/uoApi.ts new file mode 100644 index 00000000..5df1b554 --- /dev/null +++ b/src/services/userOfficeApi/uoApi.ts @@ -0,0 +1,59 @@ +import { GET_EXPERIMENT_QUERY } from './queries/getExperiment.query'; +import { GET_PROPOSAL_QUERY } from './queries/getProposal.query'; +import { UOExperimentDto } from './type/uoExperiment.type'; +import { UOProposalDto } from './type/uoProposal.type'; + +const uosGraphqlUrl = process.env.USER_OFFICE_GRAPHQL_URL; +const uosToken = process.env.USER_OFFICE_JWT; + +const graphqlRequest = async ( + query: string, + variables: Record +): Promise => { + if (!uosGraphqlUrl) { + throw new Error('USER_OFFICE_GRAPHQL_URL is not defined'); + } + + if (!uosToken) { + throw new Error('USER_OFFICE_JWT is not defined'); + } + + const response = await fetch(uosGraphqlUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${uosToken}`, + }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(`UOS GraphQL request failed: ${detail}`); + } + + const json = await response.json(); + + if (json.errors?.length) { + throw new Error(`UOS GraphQL errors: ${JSON.stringify(json.errors)}`); + } + + return json as TResponse; +}; + +export const fetchUoProposal = async (primaryKey: number) => { + const { data } = await graphqlRequest<{ data: { proposal: UOProposalDto } }>( + GET_PROPOSAL_QUERY, + { primaryKey } + ); + + return data.proposal; +}; + +export const fetchUoExperiment = async (experimentPk: number) => { + const { data } = await graphqlRequest<{ + data: { experiment: UOExperimentDto }; + }>(GET_EXPERIMENT_QUERY, { experimentPk }); + + return data.experiment; +};