From bb45214cfcc2ae66e2fe243337edbb674710a46a Mon Sep 17 00:00:00 2001 From: junjiequan Date: Mon, 18 May 2026 16:56:53 +0200 Subject: [PATCH 01/16] init --- .env.tests | 3 + src/models/Event.ts | 2 + src/models/ProposalMessage.ts | 34 +- .../upsertProposalInScicat.ts | 138 +++--- .../ProposalCreationQueueConsumer.ts | 19 +- .../utils.ts/proposalTransformer.ts | 167 +++++++ .../scicat/userOfficeApi/dto/proposal.dto.ts | 54 +++ .../queries/getExperiment.query.ts | 422 ++++++++++++++++++ .../queries/getProposal.query.ts | 398 +++++++++++++++++ .../consumers/scicat/userOfficeApi/uoApi.ts | 59 +++ .../utils/validateExperimentMessage.spec.ts | 41 ++ .../utils/validateExperimentMessage.ts | 29 ++ 12 files changed, 1282 insertions(+), 84 deletions(-) create mode 100644 src/queue/consumers/scicat/scicatProposal/utils.ts/proposalTransformer.ts create mode 100644 src/queue/consumers/scicat/userOfficeApi/dto/proposal.dto.ts create mode 100644 src/queue/consumers/scicat/userOfficeApi/queries/getExperiment.query.ts create mode 100644 src/queue/consumers/scicat/userOfficeApi/queries/getProposal.query.ts create mode 100644 src/queue/consumers/scicat/userOfficeApi/uoApi.ts create mode 100644 src/queue/consumers/utils/validateExperimentMessage.spec.ts create mode 100644 src/queue/consumers/utils/validateExperimentMessage.ts diff --git a/.env.tests b/.env.tests index 0fe7df5c..5312f0ac 100644 --- a/.env.tests +++ b/.env.tests @@ -15,6 +15,9 @@ SCICAT_USERNAME=user SCICAT_PASSWORD=pass 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 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..4c44585d 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts @@ -1,11 +1,16 @@ import { logger } from '@user-office-software/duo-logger'; import { - Instrument, + ExperimentMessageData, InstrumentDto, + ProposalMessageData, } from '../../../../../models/ProposalMessage'; -import { ValidProposalMessageData } from '../../../utils/validateProposalMessage'; -import { CreateProposalDto, UpdateProposalDto } from '../dto'; +import { UOInstrument, UOProposal } from '../../userOfficeApi/dto/proposal.dto'; +import { fetchUoExperiment, fetchUoProposal } from '../../userOfficeApi/uoApi'; +import { + getCreateProposalDto, + getUpdateProposalDto, +} from '../utils.ts/proposalTransformer'; const sciCatBaseUrl = process.env.SCICAT_BASE_URL; const sciCatLoginEndpoint = process.env.SCICAT_LOGIN_ENDPOINT || '/Users/login'; @@ -52,57 +57,17 @@ const getSciCatAccessToken = async () => { 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, + UOProposal: UOProposal, sciCatAccessToken: string ) => { const url = `${sciCatBaseUrl}/Proposals`; - const createProposalDto = getCreateProposalDto(proposalMessage); + + const scicatInstrumentIds = await getInstrumentIds(UOProposal.instruments); + const createProposalDto = getCreateProposalDto( + UOProposal, + scicatInstrumentIds + ); logger.logInfo('POST', { url }); logger.logInfo('Proposal data', { proposalData: createProposalDto }); @@ -110,9 +75,6 @@ const createProposal = async ( // 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', @@ -131,18 +93,21 @@ const createProposal = async ( }; const updateProposal = async ( - proposalMessage: ValidProposalMessageData, + UOProposal: UOProposal, sciCatAccessToken: string ) => { - const url = `${sciCatBaseUrl}/Proposals/${proposalMessage.shortCode}`; - const updateProposalDto = getUpdateProposalDto(proposalMessage); + const url = `${sciCatBaseUrl}/Proposals/${UOProposal.proposalId}`; // 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 scicatInstrumentIds = await getInstrumentIds(UOProposal.instruments); + + const updateProposalDto = getUpdateProposalDto( + UOProposal, + scicatInstrumentIds ); + const updateProposalResponse = await request(url, { method: 'PATCH', body: JSON.stringify(updateProposalDto), @@ -157,7 +122,7 @@ const updateProposal = async ( logger.logInfo('updateProposalResponse', { updateProposalResponse }); logger.logInfo('Proposal was updated in scicat', { - proposalId: proposalMessage.shortCode, + proposalId: UOProposal.proposalId, }); }; @@ -193,7 +158,7 @@ const checkProposalExists = async ( } }; -const getInstrumentIds = async (instruments: Instrument[]) => { +const getInstrumentIds = async (instruments: UOInstrument[]) => { const sciCatAccessToken = await getSciCatAccessToken(); const instrumentNames = instruments.map((inst) => inst.shortCode); @@ -216,8 +181,9 @@ const getInstrumentIds = async (instruments: Instrument[]) => { Authorization: `Bearer ${sciCatAccessToken}`, }, }); - - instrumentIds.push(res[0].pid); + if (res[0].pid) { + instrumentIds.push(res[0].pid); + } } catch (error) { logger.logError(`Error fetching instrument ID from scicat for ${name}`, { error, @@ -228,29 +194,57 @@ const getInstrumentIds = async (instruments: Instrument[]) => { return instrumentIds; }; -const upsertProposalInScicat = async ( - proposalMessage: ValidProposalMessageData -) => { +const upsertProposalInScicat = async (proposalMessage: ProposalMessageData) => { const sciCatAccessToken = await getSciCatAccessToken(); + const proposal = await fetchUoProposal(proposalMessage.proposalPk); + const proposalExists = await checkProposalExists( - proposalMessage.shortCode, + proposal.proposalId, sciCatAccessToken ); if (proposalExists) { logger.logInfo('Proposal already exists, updating...', { - proposalId: proposalMessage.shortCode, + proposalId: proposal.proposalId, }); - - updateProposal(proposalMessage, sciCatAccessToken); + updateProposal(proposal, sciCatAccessToken); } else { logger.logInfo('Proposal does not exist yet, creating...', { - proposalId: proposalMessage.shortCode, + proposalId: proposal.proposalId, + }); + + createProposal(proposal, sciCatAccessToken); + } +}; + +const upsertExperimentInScicat = async ( + experimentMessage: ExperimentMessageData +) => { + const sciCatAccessToken = await getSciCatAccessToken(); + + const experiment: any = await fetchUoExperiment( + experimentMessage.experimentPk + ); + + const experimentExists = await checkProposalExists( + experiment.proposalId, + sciCatAccessToken + ); + + if (experimentExists) { + logger.logInfo('Experiment already exists, updating...', { + experimentId: experiment.proposalId, + }); + + updateProposal(experiment, sciCatAccessToken); + } else { + logger.logInfo('Experiment does not exist yet, creating...', { + experimentId: experiment.proposalId, }); - createProposal(proposalMessage, sciCatAccessToken); + createProposal(experiment, sciCatAccessToken); } }; -export { upsertProposalInScicat }; +export { upsertProposalInScicat, upsertExperimentInScicat }; diff --git a/src/queue/consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer.ts b/src/queue/consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer.ts index e6f790c2..7442496a 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer.ts @@ -4,12 +4,18 @@ import { Event } from '../../../../../models/Event'; import { QueueConsumer } from '../../../QueueConsumer'; import { hasTriggeringStatus } from '../../../utils/hasTriggeringStatus'; import { hasTriggeringType } from '../../../utils/hasTriggeringType'; +import { validateExperimentMessage } from '../../../utils/validateExperimentMessage'; import { validateProposalMessage } from '../../../utils/validateProposalMessage'; -import { upsertProposalInScicat } from '../consumerCallbacks/upsertProposalInScicat'; +import { + upsertExperimentInScicat, + upsertProposalInScicat, +} from '../consumerCallbacks/upsertProposalInScicat'; const EVENT_TYPES = [ Event.PROPOSAL_STATUS_ACTION_EXECUTED, Event.PROPOSAL_UPDATED, + Event.EXPERIMENT_CREATED, + Event.EXPERIMENT_UPDATED, ]; const triggeringStatuses = @@ -37,8 +43,15 @@ export class ProposalCreationQueueConsumer extends QueueConsumer { return; } - const proposalMessage = validateProposalMessage(message); + const isExperiment = + type === Event.EXPERIMENT_CREATED || type === Event.EXPERIMENT_UPDATED; - upsertProposalInScicat(proposalMessage); + if (isExperiment) { + const experimentMessage = validateExperimentMessage(message); + upsertExperimentInScicat(experimentMessage); + } else { + const proposalMessage = validateProposalMessage(message); + upsertProposalInScicat(proposalMessage); + } }; } diff --git a/src/queue/consumers/scicat/scicatProposal/utils.ts/proposalTransformer.ts b/src/queue/consumers/scicat/scicatProposal/utils.ts/proposalTransformer.ts new file mode 100644 index 00000000..355070e3 --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/utils.ts/proposalTransformer.ts @@ -0,0 +1,167 @@ +import { UOProposal } from '../../userOfficeApi/dto/proposal.dto'; +import { CreateProposalDto, UpdateProposalDto } from '../dto'; + +interface MdEntryValue { + human_name: string; + value: string | number | boolean | null | undefined; +} + +const metadataEntry = ( + key: string, + human_name: string, + value: string | number | boolean | null | undefined +): [string, MdEntryValue] => [key, { human_name, value }]; + +const buildMetadata = (proposal: UOProposal): Record => { + const { + proposer, + users = [], + dataAccessUsers = [], + instruments = [], + call, + status, + } = proposal; + + const rows: [string, MdEntryValue][] = [ + metadataEntry('status', 'UOS 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_pis', 'Number of CoPIs', 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_pi_${i}_firstname`, + `CoPI ${i} First Name`, + user.firstname + ), + metadataEntry( + `co_pi_${i}_lastname`, + `CoPI ${i} Last Name`, + user.lastname + ), + metadataEntry(`co_pi_${i}_email`, `CoPI ${i} Email`, user.email), + metadataEntry(`co_pi_${i}_orcid`, `CoPI ${i} ORCID`, user.oidcSub), + metadataEntry( + `co_pi_${i}_affiliation`, + `CoPI ${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 + ), + metadataEntry( + `instrument_${i}_contact_lastname`, + `Instrument ${i} Contact Last Name`, + ic.lastname + ) + ); + }); + + return Object.fromEntries(rows); +}; + +export const getCreateProposalDto = ( + proposal: UOProposal, + instrumentIds: string[] +): CreateProposalDto => { + const { proposer } = proposal; + + return { + 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 getUpdateProposalDto = ( + proposal: UOProposal, + instrumentIds: string[] +): UpdateProposalDto => { + const { proposer } = proposal; + + return { + 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/userOfficeApi/dto/proposal.dto.ts b/src/queue/consumers/scicat/userOfficeApi/dto/proposal.dto.ts new file mode 100644 index 00000000..55ac4185 --- /dev/null +++ b/src/queue/consumers/scicat/userOfficeApi/dto/proposal.dto.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 UOProposal { + proposalId: string; + title: string; + abstract: string; + status: UOStatus; + proposer: UOUser; + users: UOUser[]; + dataAccessUsers: UOUser[]; + instruments: UOInstrument[]; + call: UOCall; +} diff --git a/src/queue/consumers/scicat/userOfficeApi/queries/getExperiment.query.ts b/src/queue/consumers/scicat/userOfficeApi/queries/getExperiment.query.ts new file mode 100644 index 00000000..68ba5612 --- /dev/null +++ b/src/queue/consumers/scicat/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/queue/consumers/scicat/userOfficeApi/queries/getProposal.query.ts b/src/queue/consumers/scicat/userOfficeApi/queries/getProposal.query.ts new file mode 100644 index 00000000..4eba4090 --- /dev/null +++ b/src/queue/consumers/scicat/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/queue/consumers/scicat/userOfficeApi/uoApi.ts b/src/queue/consumers/scicat/userOfficeApi/uoApi.ts new file mode 100644 index 00000000..5b7a6eb1 --- /dev/null +++ b/src/queue/consumers/scicat/userOfficeApi/uoApi.ts @@ -0,0 +1,59 @@ +import { UOProposal } from './dto/proposal.dto'; +import { GET_EXPERIMENT_QUERY } from './queries/getExperiment.query'; +import { GET_PROPOSAL_QUERY } from './queries/getProposal.query'; + +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: UOProposal } }>( + GET_PROPOSAL_QUERY, + { primaryKey } + ); + + return data.proposal; +}; + +export const fetchUoExperiment = async (experimentPk: number) => { + const { data } = await graphqlRequest<{ data: { experiment: unknown } }>( + GET_EXPERIMENT_QUERY, + { experimentPk } + ); + + return data.experiment; +}; 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; +} From 5b9ee9aafcba9b1ff2c16222140938b8574fda49 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Mon, 18 May 2026 17:02:52 +0200 Subject: [PATCH 02/16] fix unit test for updated proposalMessageData --- .../consumers/utils/collectUsersFromProposalMessage.spec.ts | 2 ++ 1 file changed, 2 insertions(+) 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, }); From f38e9658a692d3929c82a030c1dcffd252fc7a78 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Wed, 20 May 2026 14:38:19 +0200 Subject: [PATCH 03/16] 1. added uoAPI 2. map uoProposal to scicatProposal 3. created experiment queue --- src/index.ts | 5 + .../upsertProposalInScicat.ts | 175 +++++++---- .../ChatroomCreationQueueConsumer.ts | 4 +- .../ExperimentCreationQueueConsumer.ts | 47 +++ .../FolderCreationQueueConsumer.spec.ts | 10 +- .../consumers/FolderCreationQueueConsumer.ts | 4 +- .../ProposalCreationQueueConsumer.ts | 37 +-- .../consumers/scicat/scicatProposal/dto.ts | 37 --- .../uoToScicatExperiment.mapper.spec.ts.snap | 151 +++++++++ .../uoToScicatProposal.mapper.spec.ts.snap | 287 ++++++++++++++++++ .../uoToScicatExperiment.mapper.spec.ts | 106 +++++++ .../mappers/uoToScicatExperiment.mapper.ts | 120 ++++++++ .../mappers/uoToScicatProposal.mapper.spec.ts | 110 +++++++ .../uoToScicatProposal.mapper.ts} | 43 ++- .../type/scicatProposal.type.ts | 49 +++ .../scicat/scicatProposal/utils/common.ts | 10 + .../consumers/utils/hasTriggeringStatus.ts | 17 +- .../consumers/syncProposalQueueConsumer.ts | 4 +- src/queue/queueHandling.ts | 6 +- .../queries/getExperiment.query.ts | 0 .../queries/getProposal.query.ts | 0 .../userOfficeApi/type/uoExperiment.type.ts | 23 ++ .../userOfficeApi/type/uoProposal.type.ts} | 4 +- src/services/userOfficeApi/uoApi.spec.ts | 78 +++++ .../userOfficeApi/uoApi.ts | 12 +- 25 files changed, 1175 insertions(+), 164 deletions(-) create mode 100644 src/queue/consumers/scicat/scicatProposal/consumers/ExperimentCreationQueueConsumer.ts create mode 100644 src/queue/consumers/scicat/scicatProposal/mappers/__snapshots__/uoToScicatExperiment.mapper.spec.ts.snap create mode 100644 src/queue/consumers/scicat/scicatProposal/mappers/__snapshots__/uoToScicatProposal.mapper.spec.ts.snap create mode 100644 src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.spec.ts create mode 100644 src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts create mode 100644 src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatProposal.mapper.spec.ts rename src/queue/consumers/scicat/scicatProposal/{utils.ts/proposalTransformer.ts => mappers/uoToScicatProposal.mapper.ts} (83%) create mode 100644 src/queue/consumers/scicat/scicatProposal/type/scicatProposal.type.ts create mode 100644 src/queue/consumers/scicat/scicatProposal/utils/common.ts rename src/{queue/consumers/scicat => services}/userOfficeApi/queries/getExperiment.query.ts (100%) rename src/{queue/consumers/scicat => services}/userOfficeApi/queries/getProposal.query.ts (100%) create mode 100644 src/services/userOfficeApi/type/uoExperiment.type.ts rename src/{queue/consumers/scicat/userOfficeApi/dto/proposal.dto.ts => services/userOfficeApi/type/uoProposal.type.ts} (92%) create mode 100644 src/services/userOfficeApi/uoApi.spec.ts rename src/{queue/consumers/scicat => services}/userOfficeApi/uoApi.ts (83%) 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/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts index 4c44585d..62a5ae34 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts @@ -5,12 +5,23 @@ import { InstrumentDto, ProposalMessageData, } from '../../../../../models/ProposalMessage'; -import { UOInstrument, UOProposal } from '../../userOfficeApi/dto/proposal.dto'; -import { fetchUoExperiment, fetchUoProposal } from '../../userOfficeApi/uoApi'; +import { UOExperimentDto } from '../../../../../services/userOfficeApi/type/uoExperiment.type'; import { - getCreateProposalDto, - getUpdateProposalDto, -} from '../utils.ts/proposalTransformer'; + UOInstrument, + UOProposalDto, +} from '../../../../../services/userOfficeApi/type/uoProposal.type'; +import { + fetchUoExperiment, + fetchUoProposal, +} from '../../../../../services/userOfficeApi/uoApi'; +import { + getCreateScicatExperimentDto, + getUpdateScicatExperimentDto, +} from '../mappers/uoToScicatExperiment.mapper'; +import { + getCreateScicatProposalDto, + getUpdateScicatProposalDto, +} from '../mappers/uoToScicatProposal.mapper'; const sciCatBaseUrl = process.env.SCICAT_BASE_URL; const sciCatLoginEndpoint = process.env.SCICAT_LOGIN_ENDPOINT || '/Users/login'; @@ -58,24 +69,20 @@ const getSciCatAccessToken = async () => { }; const createProposal = async ( - UOProposal: UOProposal, + UOProposal: UOProposalDto, sciCatAccessToken: string ) => { const url = `${sciCatBaseUrl}/Proposals`; + // 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 getInstrumentIds(UOProposal.instruments); - const createProposalDto = getCreateProposalDto( + const createProposalDto = getCreateScicatProposalDto( UOProposal, scicatInstrumentIds ); - 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. - const createProposalResponse = await request(url, { method: 'POST', body: JSON.stringify(createProposalDto), @@ -85,15 +92,15 @@ const createProposal = async ( }, }); - logger.logInfo('createProposalResponse', { createProposalResponse }); - - logger.logInfo('Proposal was created in scicat', { + logger.logInfo('Proposal created in SciCat', { + url, proposalId: createProposalDto.proposalId, + response: createProposalResponse, }); }; const updateProposal = async ( - UOProposal: UOProposal, + UOProposal: UOProposalDto, sciCatAccessToken: string ) => { const url = `${sciCatBaseUrl}/Proposals/${UOProposal.proposalId}`; @@ -102,8 +109,7 @@ const updateProposal = async ( // 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 getInstrumentIds(UOProposal.instruments); - - const updateProposalDto = getUpdateProposalDto( + const updateProposalDto = getUpdateScicatProposalDto( UOProposal, scicatInstrumentIds ); @@ -117,12 +123,74 @@ const updateProposal = async ( }, }); - logger.logInfo('Patch', { url }); - logger.logInfo('Proposal data', { proposalData: updateProposalDto }); - logger.logInfo('updateProposalResponse', { updateProposalResponse }); - - logger.logInfo('Proposal was updated in scicat', { + logger.logInfo('Proposal updated in SciCat', { + url, proposalId: UOProposal.proposalId, + response: updateProposalResponse, + }); +}; + +const createExperiment = async ( + UOExperiment: UOExperimentDto, + sciCatAccessToken: string +) => { + const url = `${sciCatBaseUrl}/Proposals`; + + // 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 getInstrumentIds(UOExperiment.instrument); + const createExperimentDto = getCreateScicatExperimentDto( + UOExperiment, + scicatInstrumentIds + ); + + const createExperimentResponse = await 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, + }); +}; + +const updateExperiment = async ( + UOExperiment: UOExperimentDto, + sciCatAccessToken: string +) => { + const url = `${sciCatBaseUrl}/Proposals/${UOExperiment.experimentId}`; + + // 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 getInstrumentIds(UOExperiment.instrument); + const updateExperimentDto = getUpdateScicatExperimentDto( + UOExperiment, + scicatInstrumentIds + ); + + const updateExperimentResponse = await 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, }); }; @@ -158,9 +226,12 @@ const checkProposalExists = async ( } }; -const getInstrumentIds = async (instruments: UOInstrument[]) => { +const getInstrumentIds = async (instruments: UOInstrument | UOInstrument[]) => { const sciCatAccessToken = await getSciCatAccessToken(); - const instrumentNames = instruments.map((inst) => inst.shortCode); + const instrumentArray = Array.isArray(instruments) + ? instruments + : [instruments]; + const instrumentNames = instrumentArray.map((inst) => inst.shortCode); const instrumentIds = []; @@ -194,57 +265,45 @@ const getInstrumentIds = async (instruments: UOInstrument[]) => { return instrumentIds; }; -const upsertProposalInScicat = async (proposalMessage: ProposalMessageData) => { - const sciCatAccessToken = await getSciCatAccessToken(); - +export const upsertProposalInScicat = async ( + proposalMessage: ProposalMessageData +) => { const proposal = await fetchUoProposal(proposalMessage.proposalPk); + const scicatToken = await getSciCatAccessToken(); + const exists = await checkProposalExists(proposal.proposalId, scicatToken); - const proposalExists = await checkProposalExists( - proposal.proposalId, - sciCatAccessToken - ); - - if (proposalExists) { + if (exists) { logger.logInfo('Proposal already exists, updating...', { proposalId: proposal.proposalId, }); - updateProposal(proposal, sciCatAccessToken); + await updateProposal(proposal, scicatToken); } else { logger.logInfo('Proposal does not exist yet, creating...', { proposalId: proposal.proposalId, }); - - createProposal(proposal, sciCatAccessToken); + await createProposal(proposal, scicatToken); } }; -const upsertExperimentInScicat = async ( +export const upsertExperimentInScicat = async ( experimentMessage: ExperimentMessageData ) => { - const sciCatAccessToken = await getSciCatAccessToken(); - - const experiment: any = await fetchUoExperiment( - experimentMessage.experimentPk - ); - - const experimentExists = await checkProposalExists( - experiment.proposalId, - sciCatAccessToken + const experiment = await fetchUoExperiment(experimentMessage.experimentPk); + const scicatToken = await getSciCatAccessToken(); + const exists = await checkProposalExists( + experiment.experimentId, + scicatToken ); - if (experimentExists) { + if (exists) { logger.logInfo('Experiment already exists, updating...', { - experimentId: experiment.proposalId, + proposalId: experiment.experimentId, }); - - updateProposal(experiment, sciCatAccessToken); + await updateExperiment(experiment, scicatToken); } else { logger.logInfo('Experiment does not exist yet, creating...', { - experimentId: experiment.proposalId, + proposalId: experiment.experimentId, }); - - createProposal(experiment, sciCatAccessToken); + await createExperiment(experiment, scicatToken); } }; - -export { upsertProposalInScicat, upsertExperimentInScicat }; 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 7442496a..0a700cb9 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer.ts @@ -2,23 +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 { validateExperimentMessage } from '../../../utils/validateExperimentMessage'; import { validateProposalMessage } from '../../../utils/validateProposalMessage'; -import { - upsertExperimentInScicat, - upsertProposalInScicat, -} from '../consumerCallbacks/upsertProposalInScicat'; +import { upsertProposalInScicat } from '../consumerCallbacks/upsertProposalInScicat'; -const EVENT_TYPES = [ +const PROPOSAL_EVENT_TYPES = [ Event.PROPOSAL_STATUS_ACTION_EXECUTED, Event.PROPOSAL_UPDATED, - Event.EXPERIMENT_CREATED, - Event.EXPERIMENT_UPDATED, ]; -const triggeringStatuses = +const proposalTriggeringStatuses = process.env.SCICAT_PROPOSAL_TRIGGERING_STATUSES?.split(', '); export class ProposalCreationQueueConsumer extends QueueConsumer { @@ -31,27 +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 isExperiment = - type === Event.EXPERIMENT_CREATED || type === Event.EXPERIMENT_UPDATED; - - if (isExperiment) { - const experimentMessage = validateExperimentMessage(message); - upsertExperimentInScicat(experimentMessage); - } else { - const proposalMessage = validateProposalMessage(message); - upsertProposalInScicat(proposalMessage); - } + 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..c2d318d0 --- /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": "fredrik@ess.eu", + }, + "visitor_1_firstname": { + "human_name": "Visitor 1 First Name", + "value": "Fredrik", + }, + "visitor_1_lastname": { + "human_name": "Visitor 1 Last Name", + "value": "Bolmsten", + }, + "visitor_1_orcid": { + "human_name": "Visitor 1 ORCID", + "value": "fredrikbolmsten", + }, + }, + "ownerGroup": "user", + "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": "user", + "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": "user", + "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..0f484942 --- /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_pi_1_affiliation": { + "human_name": "CoPI 1 Affiliation", + "value": "ESS", + }, + "co_pi_1_email": { + "human_name": "CoPI 1 Email", + "value": "jane@example.com", + }, + "co_pi_1_firstname": { + "human_name": "CoPI 1 First Name", + "value": "Jane", + }, + "co_pi_1_lastname": { + "human_name": "CoPI 1 Last Name", + "value": "Smith", + }, + "co_pi_1_orcid": { + "human_name": "CoPI 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_pis": { + "human_name": "Number of CoPIs", + "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_pis": { + "human_name": "Number of CoPIs", + "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_pis": { + "human_name": "Number of CoPIs", + "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..28a0fa67 --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.spec.ts @@ -0,0 +1,106 @@ +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: 'Fredrik', + lastname: 'Bolmsten', + email: 'fredrik@ess.eu', + oidcSub: 'fredrikbolmsten', + 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..479307e9 --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts @@ -0,0 +1,120 @@ +import { UOExperimentDto } from '../../../../../services/userOfficeApi/type/uoExperiment.type'; +import { + CreateScicatProposalDto, + MdEntry, + MdEntryValue, + UpdateScicatProposalDto, +} from '../type/scicatProposal.type'; +import { metadataEntry } from '../utils/common'; + +const sciCatUsername = process.env.SCICAT_USERNAME; + +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: sciCatUsername || '', + 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: sciCatUsername, + accessGroups: [experimentId], + startTime: new Date(experiment.startsAt), + endTime: new Date(experiment.endsAt), + MeasurementPeriodList: [], + metadata: buildMetadata(experiment), + }; +}; 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/utils.ts/proposalTransformer.ts b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatProposal.mapper.ts similarity index 83% rename from src/queue/consumers/scicat/scicatProposal/utils.ts/proposalTransformer.ts rename to src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatProposal.mapper.ts index 355070e3..5fc36edc 100644 --- a/src/queue/consumers/scicat/scicatProposal/utils.ts/proposalTransformer.ts +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatProposal.mapper.ts @@ -1,18 +1,13 @@ -import { UOProposal } from '../../userOfficeApi/dto/proposal.dto'; -import { CreateProposalDto, UpdateProposalDto } from '../dto'; +import { UOProposalDto } from '../../../../../services/userOfficeApi/type/uoProposal.type'; +import { + CreateScicatProposalDto, + MdEntry, + MdEntryValue, + UpdateScicatProposalDto, +} from '../type/scicatProposal.type'; +import { metadataEntry } from '../utils/common'; -interface MdEntryValue { - human_name: string; - value: string | number | boolean | null | undefined; -} - -const metadataEntry = ( - key: string, - human_name: string, - value: string | number | boolean | null | undefined -): [string, MdEntryValue] => [key, { human_name, value }]; - -const buildMetadata = (proposal: UOProposal): Record => { +const buildMetadata = (proposal: UOProposalDto): MdEntry => { const { proposer, users = [], @@ -23,7 +18,7 @@ const buildMetadata = (proposal: UOProposal): Record => { } = proposal; const rows: [string, MdEntryValue][] = [ - metadataEntry('status', 'UOS Status', status.name), + 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), @@ -102,12 +97,12 @@ const buildMetadata = (proposal: UOProposal): Record => { metadataEntry( `instrument_${i}_contact_firstname`, `Instrument ${i} Contact First Name`, - ic.firstname + ic?.firstname || null ), metadataEntry( `instrument_${i}_contact_lastname`, `Instrument ${i} Contact Last Name`, - ic.lastname + ic?.lastname || null ) ); }); @@ -115,13 +110,14 @@ const buildMetadata = (proposal: UOProposal): Record => { return Object.fromEntries(rows); }; -export const getCreateProposalDto = ( - proposal: UOProposal, +export const getCreateScicatProposalDto = ( + proposal: UOProposalDto, instrumentIds: string[] -): CreateProposalDto => { +): CreateScicatProposalDto => { const { proposer } = proposal; return { + type: 'Proposal', proposalId: proposal.proposalId, title: proposal.title, abstract: proposal.abstract, @@ -141,13 +137,14 @@ export const getCreateProposalDto = ( }; }; -export const getUpdateProposalDto = ( - proposal: UOProposal, +export const getUpdateScicatProposalDto = ( + proposal: UOProposalDto, instrumentIds: string[] -): UpdateProposalDto => { +): UpdateScicatProposalDto => { const { proposer } = proposal; return { + type: 'Proposal', title: proposal.title, abstract: proposal.abstract, firstname: proposer.firstname, 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..83452eae --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/type/scicatProposal.type.ts @@ -0,0 +1,49 @@ +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; +}; 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/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/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/queue/consumers/scicat/userOfficeApi/queries/getExperiment.query.ts b/src/services/userOfficeApi/queries/getExperiment.query.ts similarity index 100% rename from src/queue/consumers/scicat/userOfficeApi/queries/getExperiment.query.ts rename to src/services/userOfficeApi/queries/getExperiment.query.ts diff --git a/src/queue/consumers/scicat/userOfficeApi/queries/getProposal.query.ts b/src/services/userOfficeApi/queries/getProposal.query.ts similarity index 100% rename from src/queue/consumers/scicat/userOfficeApi/queries/getProposal.query.ts rename to src/services/userOfficeApi/queries/getProposal.query.ts diff --git a/src/services/userOfficeApi/type/uoExperiment.type.ts b/src/services/userOfficeApi/type/uoExperiment.type.ts new file mode 100644 index 00000000..535ca7bf --- /dev/null +++ b/src/services/userOfficeApi/type/uoExperiment.type.ts @@ -0,0 +1,23 @@ +import { UOProposalDto, UOUser } from './uoProposal.type'; + +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; +} diff --git a/src/queue/consumers/scicat/userOfficeApi/dto/proposal.dto.ts b/src/services/userOfficeApi/type/uoProposal.type.ts similarity index 92% rename from src/queue/consumers/scicat/userOfficeApi/dto/proposal.dto.ts rename to src/services/userOfficeApi/type/uoProposal.type.ts index 55ac4185..dc701d9c 100644 --- a/src/queue/consumers/scicat/userOfficeApi/dto/proposal.dto.ts +++ b/src/services/userOfficeApi/type/uoProposal.type.ts @@ -20,7 +20,7 @@ export interface UOInstrument { id: number; name: string; shortCode: string; - instrumentContact: UOInstrumentContact; + instrumentContact?: UOInstrumentContact; managementTimeAllocation?: number; } @@ -41,7 +41,7 @@ export interface UOStatus { description?: string; } -export interface UOProposal { +export interface UOProposalDto { proposalId: string; title: string; abstract: string; 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/queue/consumers/scicat/userOfficeApi/uoApi.ts b/src/services/userOfficeApi/uoApi.ts similarity index 83% rename from src/queue/consumers/scicat/userOfficeApi/uoApi.ts rename to src/services/userOfficeApi/uoApi.ts index 5b7a6eb1..5df1b554 100644 --- a/src/queue/consumers/scicat/userOfficeApi/uoApi.ts +++ b/src/services/userOfficeApi/uoApi.ts @@ -1,6 +1,7 @@ -import { UOProposal } from './dto/proposal.dto'; 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; @@ -41,7 +42,7 @@ const graphqlRequest = async ( }; export const fetchUoProposal = async (primaryKey: number) => { - const { data } = await graphqlRequest<{ data: { proposal: UOProposal } }>( + const { data } = await graphqlRequest<{ data: { proposal: UOProposalDto } }>( GET_PROPOSAL_QUERY, { primaryKey } ); @@ -50,10 +51,9 @@ export const fetchUoProposal = async (primaryKey: number) => { }; export const fetchUoExperiment = async (experimentPk: number) => { - const { data } = await graphqlRequest<{ data: { experiment: unknown } }>( - GET_EXPERIMENT_QUERY, - { experimentPk } - ); + const { data } = await graphqlRequest<{ + data: { experiment: UOExperimentDto }; + }>(GET_EXPERIMENT_QUERY, { experimentPk }); return data.experiment; }; From 1214363fb9099e059755e28edad9597b9ad219d7 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 21 May 2026 09:51:57 +0200 Subject: [PATCH 04/16] Move scicat apis to new file --- .../upsertProposalInScicat.ts | 277 +-------------- .../consumerCallbacks/upsertSampleInScicat.ts | 37 ++ .../mappers/uoToScicatExperiment.mapper.ts | 111 +++++- .../type/scicatProposal.type.ts | 19 + .../scicat/scicatProposal/utils/scicatApi.ts | 328 ++++++++++++++++++ .../userOfficeApi/type/uoExperiment.type.ts | 21 ++ 6 files changed, 521 insertions(+), 272 deletions(-) create mode 100644 src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertSampleInScicat.ts create mode 100644 src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts diff --git a/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts index 62a5ae34..a88186b0 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts @@ -1,287 +1,32 @@ import { logger } from '@user-office-software/duo-logger'; +import { upsertSamplesInScicat } from './upsertSampleInScicat'; import { ExperimentMessageData, - InstrumentDto, ProposalMessageData, } from '../../../../../models/ProposalMessage'; -import { UOExperimentDto } from '../../../../../services/userOfficeApi/type/uoExperiment.type'; -import { - UOInstrument, - UOProposalDto, -} from '../../../../../services/userOfficeApi/type/uoProposal.type'; import { fetchUoExperiment, fetchUoProposal, } from '../../../../../services/userOfficeApi/uoApi'; -import { - getCreateScicatExperimentDto, - getUpdateScicatExperimentDto, -} from '../mappers/uoToScicatExperiment.mapper'; -import { - getCreateScicatProposalDto, - getUpdateScicatProposalDto, -} from '../mappers/uoToScicatProposal.mapper'; - -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 createProposal = async ( - UOProposal: UOProposalDto, - sciCatAccessToken: string -) => { - const url = `${sciCatBaseUrl}/Proposals`; - - // 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 getInstrumentIds(UOProposal.instruments); - const createProposalDto = getCreateScicatProposalDto( - UOProposal, - scicatInstrumentIds - ); - - const createProposalResponse = await 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, - }); -}; - -const updateProposal = async ( - UOProposal: UOProposalDto, - sciCatAccessToken: string -) => { - const url = `${sciCatBaseUrl}/Proposals/${UOProposal.proposalId}`; - - // 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 getInstrumentIds(UOProposal.instruments); - const updateProposalDto = getUpdateScicatProposalDto( - UOProposal, - scicatInstrumentIds - ); - - const updateProposalResponse = await 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, - }); -}; - -const createExperiment = async ( - UOExperiment: UOExperimentDto, - sciCatAccessToken: string -) => { - const url = `${sciCatBaseUrl}/Proposals`; - - // 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 getInstrumentIds(UOExperiment.instrument); - const createExperimentDto = getCreateScicatExperimentDto( - UOExperiment, - scicatInstrumentIds - ); - - const createExperimentResponse = await 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, - }); -}; - -const updateExperiment = async ( - UOExperiment: UOExperimentDto, - sciCatAccessToken: string -) => { - const url = `${sciCatBaseUrl}/Proposals/${UOExperiment.experimentId}`; - - // 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 getInstrumentIds(UOExperiment.instrument); - const updateExperimentDto = getUpdateScicatExperimentDto( - UOExperiment, - scicatInstrumentIds - ); - - const updateExperimentResponse = await 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, - }); -}; - -const checkProposalExists = async ( - proposalId: string, - sciCatAccessToken: string -) => { - 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; - }); - - if (response) { - return true; - } else { - return false; - } -}; - -const getInstrumentIds = async (instruments: UOInstrument | UOInstrument[]) => { - const sciCatAccessToken = await getSciCatAccessToken(); - 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 = `${sciCatBaseUrl}/Instruments?filter=${encodeURIComponent(filterString)}`; - - try { - const res = await 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; -}; +import { scicatApi } from '../utils/scicatApi'; export const upsertProposalInScicat = async ( proposalMessage: ProposalMessageData ) => { const proposal = await fetchUoProposal(proposalMessage.proposalPk); - const scicatToken = await getSciCatAccessToken(); - const exists = await checkProposalExists(proposal.proposalId, scicatToken); + const exists = await scicatApi.checkProposalExists(proposal.proposalId); if (exists) { logger.logInfo('Proposal already exists, updating...', { proposalId: proposal.proposalId, }); - await updateProposal(proposal, scicatToken); + await scicatApi.updateProposal(proposal); } else { logger.logInfo('Proposal does not exist yet, creating...', { proposalId: proposal.proposalId, }); - await createProposal(proposal, scicatToken); + await scicatApi.createProposal(proposal); } }; @@ -289,21 +34,19 @@ export const upsertExperimentInScicat = async ( experimentMessage: ExperimentMessageData ) => { const experiment = await fetchUoExperiment(experimentMessage.experimentPk); - const scicatToken = await getSciCatAccessToken(); - const exists = await checkProposalExists( - experiment.experimentId, - scicatToken - ); + const exists = await scicatApi.checkProposalExists(experiment.experimentId); if (exists) { logger.logInfo('Experiment already exists, updating...', { proposalId: experiment.experimentId, }); - await updateExperiment(experiment, scicatToken); + await scicatApi.updateExperiment(experiment); } else { logger.logInfo('Experiment does not exist yet, creating...', { proposalId: experiment.experimentId, }); - await createExperiment(experiment, scicatToken); + await scicatApi.createExperiment(experiment); } + + 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/mappers/uoToScicatExperiment.mapper.ts b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts index 479307e9..09822cdb 100644 --- a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts @@ -1,14 +1,23 @@ -import { UOExperimentDto } from '../../../../../services/userOfficeApi/type/uoExperiment.type'; +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'; -const sciCatUsername = process.env.SCICAT_USERNAME; - +// ────────────────────────────────────────────────────────────────────────────── +// ── Experiment ──────────────────────────────────────────────────────────────── +// ────────────────────────────────────────────────────────────────────────────── const buildMetadata = (experiment: UOExperimentDto): MdEntry => { const { proposal, instrument, visit } = experiment; const registrations = visit?.registrations ?? []; @@ -82,7 +91,7 @@ export const getCreateScicatExperimentDto = ( pi_lastname: proposer.lastname, pi_email: proposer.email, instrumentIds, - ownerGroup: sciCatUsername || '', + ownerGroup: scicatApi.username || '', accessGroups: [experimentId], startTime: new Date(experiment.startsAt), endTime: new Date(experiment.endsAt), @@ -110,7 +119,7 @@ export const getUpdateScicatExperimentDto = ( pi_lastname: proposer.lastname, pi_email: proposer.email, instrumentIds, - ownerGroup: sciCatUsername, + ownerGroup: scicatApi.username, accessGroups: [experimentId], startTime: new Date(experiment.startsAt), endTime: new Date(experiment.endsAt), @@ -118,3 +127,95 @@ export const getUpdateScicatExperimentDto = ( metadata: buildMetadata(experiment), }; }; + +// ────────────────────────────────────────────────────────────────────────────── +// ── Sample ──────────────────────────────────────────────────────────────────── +// ────────────────────────────────────────────────────────────────────────────── + +const buildSampleHash = (dto: CreateScicatSampleDto): string => { + const hashableData = { + accessGroups: dto.accessGroups, + description: dto.description, + 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) + .filter((f) => f.question.id !== 'sample_esi_basis' && f.value !== null); + + const rows: [string, MdEntryValue][] = [ + metadataEntry('uos_sample_id', 'UOS Sample ID', sample.sampleId), + metadataEntry( + 'uos_sample_lookup', + 'UOS Sample Lookup', + `${experiment.experimentId}_${sample.sampleId}` + ), + 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.username || '', + type: 'Sample Information', + accessGroups: [experiment.experimentId], + proposalId: experiment.experimentId, + description: sample.sample.title, //TODO: this should be replaced with sampleName before merge + 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.username || '', + accessGroups: [experiment.experimentId], + proposalId: experiment.experimentId, + description: sample.sample.title, //TODO: this should be replaced with sampleName before merge + isPublished: false, + sampleCharacteristics, + }; + dto.sampleCharacteristics['sample_hash'] = { + human_name: 'Sample Hash', + value: buildSampleHash(dto), + }; + + return dto; +}; diff --git a/src/queue/consumers/scicat/scicatProposal/type/scicatProposal.type.ts b/src/queue/consumers/scicat/scicatProposal/type/scicatProposal.type.ts index 83452eae..79b98d2e 100644 --- a/src/queue/consumers/scicat/scicatProposal/type/scicatProposal.type.ts +++ b/src/queue/consumers/scicat/scicatProposal/type/scicatProposal.type.ts @@ -47,3 +47,22 @@ export type UpdateScicatProposalDto = { MeasurementPeriodList?: any[]; metadata?: Record; }; + +export type CreateScicatSampleDto = { + proposalId: string; + description: string; + ownerGroup: string; + accessGroups?: string[]; + isPublished?: boolean; + sampleCharacteristics?: Record; +}; + +export type UpdateScicatSampleDto = { + sampleId?: string; + description?: string; + proposalId?: string; + ownerGroup?: string; + accessGroups?: string[]; + isPublished?: boolean; + sampleCharacteristics?: Record; +}; 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..7ecf4dd6 --- /dev/null +++ b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts @@ -0,0 +1,328 @@ +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 { + CreateScicatSampleDto, + UpdateScicatSampleDto, +} from '../type/scicatProposal.type'; + +class ScicatApi { + readonly baseUrl = process.env.SCICAT_BASE_URL; + readonly loginEndpoint = process.env.SCICAT_LOGIN_ENDPOINT || '/Users/login'; + readonly username = process.env.SCICAT_USERNAME; + readonly password = process.env.SCICAT_PASSWORD; + + 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 getAccessToken(): Promise { + const { access_token: sciCatAccessToken } = await this.request<{ + access_token: string; + }>(`${this.baseUrl}${this.loginEndpoint}`, { + method: 'POST', + body: JSON.stringify({ + username: this.username, + password: this.password, + }), + headers: { 'Content-Type': 'application/json' }, + }); + + if (!sciCatAccessToken) { + throw new Error('No access token found'); + } + + return sciCatAccessToken; + } + + async getInstrumentIds( + instruments: UOInstrument | UOInstrument[] + ): Promise { + const sciCatAccessToken = await this.getAccessToken(); + 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 = await this.getAccessToken(); + 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 = await this.getAccessToken(); + 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 = await this.getAccessToken(); + // 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 = await this.getAccessToken(); + // 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 = await this.getAccessToken(); + // 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, + }); + + // await createSamples(UOExperiment, sciCatAccessToken); + } + + async updateExperiment(UOExperiment: UOExperimentDto) { + const url = `${this.baseUrl}/Proposals/${UOExperiment.experimentId}`; + const sciCatAccessToken = await this.getAccessToken(); + // 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 = await this.getAccessToken(); + 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 = await this.getAccessToken(); + + 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/services/userOfficeApi/type/uoExperiment.type.ts b/src/services/userOfficeApi/type/uoExperiment.type.ts index 535ca7bf..9c4e3a12 100644 --- a/src/services/userOfficeApi/type/uoExperiment.type.ts +++ b/src/services/userOfficeApi/type/uoExperiment.type.ts @@ -1,5 +1,23 @@ 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; @@ -20,4 +38,7 @@ export interface UOExperimentDto { user: UOUser | null; }[]; } | null; + experimentSafety?: { + samples: UOExperimentSafetySample[]; + } | null; } From 106cb3bcb2988e0bb8ca2aa94924d2350cbf28e1 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 21 May 2026 10:04:20 +0200 Subject: [PATCH 05/16] remove unncessary commit message --- src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts index 7ecf4dd6..21884c35 100644 --- a/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts +++ b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts @@ -250,8 +250,6 @@ class ScicatApi { proposalId: UOExperiment.experimentId, response: createExperimentResponse, }); - - // await createSamples(UOExperiment, sciCatAccessToken); } async updateExperiment(UOExperiment: UOExperimentDto) { From 9a8e845b112c4dec52e1d80f8246cf235cce933e Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 21 May 2026 10:28:17 +0200 Subject: [PATCH 06/16] do not filter any questionaries, show all --- .../scicatProposal/mappers/uoToScicatExperiment.mapper.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts index 09822cdb..c50878cc 100644 --- a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts @@ -149,9 +149,7 @@ const buildSampleCharacteristics = ( sample: UOExperimentSafetySample, experiment: UOExperimentDto ): Record => { - const fields = sample.questionary.steps - .flatMap((step) => step.fields) - .filter((f) => f.question.id !== 'sample_esi_basis' && f.value !== null); + const fields = sample.questionary.steps.flatMap((step) => step.fields); const rows: [string, MdEntryValue][] = [ metadataEntry('uos_sample_id', 'UOS Sample ID', sample.sampleId), From e877c43e1021fc79e2743a7568ee9a8eb6f0cc17 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 21 May 2026 16:49:31 +0200 Subject: [PATCH 07/16] Fix wrong format for UOS Sample Lookup and create variable for code search --- .../mappers/uoToScicatExperiment.mapper.spec.ts | 8 ++++---- .../scicatProposal/mappers/uoToScicatExperiment.mapper.ts | 7 ++----- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.spec.ts b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.spec.ts index 28a0fa67..7fd75438 100644 --- a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.spec.ts +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.spec.ts @@ -55,10 +55,10 @@ describe('getCreateScicatExperimentDto', () => { status: 'DRAFTED', user: { id: 7, - firstname: 'Fredrik', - lastname: 'Bolmsten', - email: 'fredrik@ess.eu', - oidcSub: 'fredrikbolmsten', + firstname: 'testFirst', + lastname: 'testLast', + email: 'test@example.com', + oidcSub: 'testOidcSub', institution: 'ESS', }, }, diff --git a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts index c50878cc..49a2953d 100644 --- a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts @@ -150,14 +150,11 @@ const buildSampleCharacteristics = ( 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', - `${experiment.experimentId}_${sample.sampleId}` - ), + metadataEntry('uos_sample_lookup', 'UOS Sample Lookup', sampleLookup), metadataEntry( 'questionary_length', 'Number of questions in Questionary', From 458b0a5fc5b2bd81166dc72d7a394407a540a818 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 21 May 2026 16:57:25 +0200 Subject: [PATCH 08/16] update uoToScicatProposal.mapper unit tests snapshot --- .../uoToScicatExperiment.mapper.spec.ts.snap | 8 ++--- .../uoToScicatProposal.mapper.spec.ts.snap | 32 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) 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 index c2d318d0..dfe5ac53 100644 --- 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 @@ -34,19 +34,19 @@ Test Abstract", }, "visitor_1_email": { "human_name": "Visitor 1 Email", - "value": "fredrik@ess.eu", + "value": "test@example.com", }, "visitor_1_firstname": { "human_name": "Visitor 1 First Name", - "value": "Fredrik", + "value": "testFirst", }, "visitor_1_lastname": { "human_name": "Visitor 1 Last Name", - "value": "Bolmsten", + "value": "testLast", }, "visitor_1_orcid": { "human_name": "Visitor 1 ORCID", - "value": "fredrikbolmsten", + "value": "testOidcSub", }, }, "ownerGroup": "user", 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 index 0f484942..dce6907e 100644 --- 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 @@ -21,24 +21,24 @@ exports[`getCreateScicatProposalDto includes co-PI, DAU and instrument metadata "human_name": "Call", "value": "CALL-2025", }, - "co_pi_1_affiliation": { - "human_name": "CoPI 1 Affiliation", + "co_i_1_affiliation": { + "human_name": "CoI 1 Affiliation", "value": "ESS", }, - "co_pi_1_email": { - "human_name": "CoPI 1 Email", + "co_i_1_email": { + "human_name": "CoI 1 Email", "value": "jane@example.com", }, - "co_pi_1_firstname": { - "human_name": "CoPI 1 First Name", + "co_i_1_firstname": { + "human_name": "CoI 1 First Name", "value": "Jane", }, - "co_pi_1_lastname": { - "human_name": "CoPI 1 Last Name", + "co_i_1_lastname": { + "human_name": "CoI 1 Last Name", "value": "Smith", }, - "co_pi_1_orcid": { - "human_name": "CoPI 1 ORCID", + "co_i_1_orcid": { + "human_name": "CoI 1 ORCID", "value": "0000-0002-3456-7890", }, "dau_1_email": { @@ -93,8 +93,8 @@ exports[`getCreateScicatProposalDto includes co-PI, DAU and instrument metadata "human_name": "Instrument 1 Name", "value": "YMIR", }, - "number_of_co_pis": { - "human_name": "Number of CoPIs", + "number_of_co_is": { + "human_name": "Number of CoIs", "value": 1, }, "number_of_dau": { @@ -166,8 +166,8 @@ exports[`getCreateScicatProposalDto maps proposal to create DTO correctly 1`] = "human_name": "Call End Date", "value": "2025-12-31T00:00:00.000Z", }, - "number_of_co_pis": { - "human_name": "Number of CoPIs", + "number_of_co_is": { + "human_name": "Number of CoIs", "value": 0, }, "number_of_dau": { @@ -239,8 +239,8 @@ exports[`getUpdateScicatProposalDto maps proposal to update DTO correctly 1`] = "human_name": "Call End Date", "value": "2025-12-31T00:00:00.000Z", }, - "number_of_co_pis": { - "human_name": "Number of CoPIs", + "number_of_co_is": { + "human_name": "Number of CoIs", "value": 0, }, "number_of_dau": { From 618d005bec465088715157ed7e04af171c4a0b94 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 21 May 2026 16:57:51 +0200 Subject: [PATCH 09/16] rename co-pi to co-i --- .../mappers/uoToScicatProposal.mapper.ts | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatProposal.mapper.ts b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatProposal.mapper.ts index 5fc36edc..186d818f 100644 --- a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatProposal.mapper.ts +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatProposal.mapper.ts @@ -23,7 +23,7 @@ const buildMetadata = (proposal: UOProposalDto): MdEntry => { 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_pis', 'Number of CoPIs', users.length), + metadataEntry('number_of_co_is', 'Number of CoIs', users.length), metadataEntry( 'number_of_dau', 'Number of Data Access Users', @@ -45,20 +45,16 @@ const buildMetadata = (proposal: UOProposalDto): MdEntry => { const i = index + 1; rows.push( metadataEntry( - `co_pi_${i}_firstname`, - `CoPI ${i} First Name`, + `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_pi_${i}_lastname`, - `CoPI ${i} Last Name`, - user.lastname - ), - metadataEntry(`co_pi_${i}_email`, `CoPI ${i} Email`, user.email), - metadataEntry(`co_pi_${i}_orcid`, `CoPI ${i} ORCID`, user.oidcSub), - metadataEntry( - `co_pi_${i}_affiliation`, - `CoPI ${i} Affiliation`, + `co_i_${i}_affiliation`, + `CoI ${i} Affiliation`, user.institution ) ); From 63cf77594bd0645a1c21698f9c799d1270b01065 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 21 May 2026 17:37:56 +0200 Subject: [PATCH 10/16] get scicat JWT token from ENV variable --- .../mappers/uoToScicatExperiment.mapper.ts | 8 +-- .../scicat/scicatProposal/utils/scicatApi.ts | 69 ++++++++++--------- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts index 49a2953d..ac6da4de 100644 --- a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts @@ -91,7 +91,7 @@ export const getCreateScicatExperimentDto = ( pi_lastname: proposer.lastname, pi_email: proposer.email, instrumentIds, - ownerGroup: scicatApi.username || '', + ownerGroup: scicatApi.serviceUsername || '', accessGroups: [experimentId], startTime: new Date(experiment.startsAt), endTime: new Date(experiment.endsAt), @@ -119,7 +119,7 @@ export const getUpdateScicatExperimentDto = ( pi_lastname: proposer.lastname, pi_email: proposer.email, instrumentIds, - ownerGroup: scicatApi.username, + ownerGroup: scicatApi.serviceUsername, accessGroups: [experimentId], startTime: new Date(experiment.startsAt), endTime: new Date(experiment.endsAt), @@ -178,7 +178,7 @@ export const getCreateScicatSampleDto = ( ): CreateScicatSampleDto => { const sampleCharacteristics = buildSampleCharacteristics(sample, experiment); const dto = { - ownerGroup: scicatApi.username || '', + ownerGroup: scicatApi.serviceUsername || '', type: 'Sample Information', accessGroups: [experiment.experimentId], proposalId: experiment.experimentId, @@ -200,7 +200,7 @@ export const getUpdateScicatSampleDto = ( ): UpdateScicatSampleDto => { const sampleCharacteristics = buildSampleCharacteristics(sample, experiment); const dto = { - ownerGroup: scicatApi.username || '', + ownerGroup: scicatApi.serviceUsername || '', accessGroups: [experiment.experimentId], proposalId: experiment.experimentId, description: sample.sample.title, //TODO: this should be replaced with sampleName before merge diff --git a/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts index 21884c35..8f58bde1 100644 --- a/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts +++ b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts @@ -21,9 +21,35 @@ import { class ScicatApi { readonly baseUrl = process.env.SCICAT_BASE_URL; - readonly loginEndpoint = process.env.SCICAT_LOGIN_ENDPOINT || '/Users/login'; - readonly username = process.env.SCICAT_USERNAME; - readonly password = process.env.SCICAT_PASSWORD; + 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); + } + + 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, @@ -42,29 +68,10 @@ class ScicatApi { return JSON.parse(text) as TResponse; } - async getAccessToken(): Promise { - const { access_token: sciCatAccessToken } = await this.request<{ - access_token: string; - }>(`${this.baseUrl}${this.loginEndpoint}`, { - method: 'POST', - body: JSON.stringify({ - username: this.username, - password: this.password, - }), - headers: { 'Content-Type': 'application/json' }, - }); - - if (!sciCatAccessToken) { - throw new Error('No access token found'); - } - - return sciCatAccessToken; - } - async getInstrumentIds( instruments: UOInstrument | UOInstrument[] ): Promise { - const sciCatAccessToken = await this.getAccessToken(); + const sciCatAccessToken = this.scicatToken; const instrumentArray = Array.isArray(instruments) ? instruments : [instruments]; @@ -107,7 +114,7 @@ class ScicatApi { async checkProposalExists(proposalId: string): Promise { const url = `${this.baseUrl}/Proposals/${proposalId}`; - const sciCatAccessToken = await this.getAccessToken(); + const sciCatAccessToken = this.scicatToken; const response = await this.request(url, { headers: { 'Content-Type': 'application/json', @@ -134,7 +141,7 @@ class ScicatApi { async findSampleByLookup( sampleLookup: string ): Promise { - const sciCatAccessToken = await this.getAccessToken(); + const sciCatAccessToken = this.scicatToken; const filter = JSON.stringify({ where: { 'sampleCharacteristics.uos_sample_lookup.value': sampleLookup, @@ -163,7 +170,7 @@ class ScicatApi { async createProposal(UOProposal: UOProposalDto) { const url = `${this.baseUrl}/Proposals`; - const sciCatAccessToken = await this.getAccessToken(); + 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. @@ -193,7 +200,7 @@ class ScicatApi { async updateProposal(UOProposal: UOProposalDto) { const url = `${this.baseUrl}/Proposals/${UOProposal.proposalId}`; - const sciCatAccessToken = await this.getAccessToken(); + 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. @@ -223,7 +230,7 @@ class ScicatApi { async createExperiment(UOExperiment: UOExperimentDto) { const url = `${this.baseUrl}/Proposals`; - const sciCatAccessToken = await this.getAccessToken(); + 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. @@ -254,7 +261,7 @@ class ScicatApi { async updateExperiment(UOExperiment: UOExperimentDto) { const url = `${this.baseUrl}/Proposals/${UOExperiment.experimentId}`; - const sciCatAccessToken = await this.getAccessToken(); + 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. @@ -284,7 +291,7 @@ class ScicatApi { } async createSample(dto: CreateScicatSampleDto): Promise { - const sciCatAccessToken = await this.getAccessToken(); + const sciCatAccessToken = this.scicatToken; const url = `${this.baseUrl}/Samples`; const createSampleResponse = await this.request(url, { @@ -306,7 +313,7 @@ class ScicatApi { sampleId: string, dto: UpdateScicatSampleDto ): Promise { - const sciCatAccessToken = await this.getAccessToken(); + const sciCatAccessToken = this.scicatToken; const url = `${this.baseUrl}/Samples/${sampleId}`; From fc5a4952c3da809b170970d177b93a5be0df3e69 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 21 May 2026 17:41:41 +0200 Subject: [PATCH 11/16] print scicatApi username on init --- src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts index 8f58bde1..1fa13f58 100644 --- a/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts +++ b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts @@ -34,6 +34,11 @@ class ScicatApi { } this.serviceUsername = this.getServiceUsername(this.scicatToken); + + logger.logInfo('ScicatApi initialized', { + baseUrl: this.baseUrl, + serviceUsername: this.serviceUsername, + }); } private getServiceUsername(token: string): string { From 960349af5141e02b8208ff60477052ca4d032c1c Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 21 May 2026 17:59:10 +0200 Subject: [PATCH 12/16] replace descriotipn with sampleName for sampleDto --- .../__snapshots__/uoToScicatExperiment.mapper.spec.ts.snap | 6 +++--- .../scicatProposal/mappers/uoToScicatExperiment.mapper.ts | 6 +++--- .../scicat/scicatProposal/type/scicatProposal.type.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) 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 index dfe5ac53..cbe13f31 100644 --- 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 @@ -49,7 +49,7 @@ Test Abstract", "value": "testOidcSub", }, }, - "ownerGroup": "user", + "ownerGroup": "testuser", "parentProposalId": "158548", "pi_email": "john.doe@example.com", "pi_firstname": "John", @@ -94,7 +94,7 @@ Test Abstract", "value": "ALLOCATED", }, }, - "ownerGroup": "user", + "ownerGroup": "testuser", "parentProposalId": "158548", "pi_email": "john.doe@example.com", "pi_firstname": "John", @@ -139,7 +139,7 @@ Test Abstract", "value": "ALLOCATED", }, }, - "ownerGroup": "user", + "ownerGroup": "testuser", "parentProposalId": "158548", "pi_email": "john.doe@example.com", "pi_firstname": "John", diff --git a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts index ac6da4de..6eb955db 100644 --- a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.ts @@ -135,7 +135,7 @@ export const getUpdateScicatExperimentDto = ( const buildSampleHash = (dto: CreateScicatSampleDto): string => { const hashableData = { accessGroups: dto.accessGroups, - description: dto.description, + sampleName: dto.sampleName, isPublished: dto.isPublished, ownerGroup: dto.ownerGroup, proposalId: dto.proposalId, @@ -182,7 +182,7 @@ export const getCreateScicatSampleDto = ( type: 'Sample Information', accessGroups: [experiment.experimentId], proposalId: experiment.experimentId, - description: sample.sample.title, //TODO: this should be replaced with sampleName before merge + sampleName: sample.sample.title, isPublished: false, sampleCharacteristics, }; @@ -203,7 +203,7 @@ export const getUpdateScicatSampleDto = ( ownerGroup: scicatApi.serviceUsername || '', accessGroups: [experiment.experimentId], proposalId: experiment.experimentId, - description: sample.sample.title, //TODO: this should be replaced with sampleName before merge + sampleName: sample.sample.title, isPublished: false, sampleCharacteristics, }; diff --git a/src/queue/consumers/scicat/scicatProposal/type/scicatProposal.type.ts b/src/queue/consumers/scicat/scicatProposal/type/scicatProposal.type.ts index 79b98d2e..612e58ec 100644 --- a/src/queue/consumers/scicat/scicatProposal/type/scicatProposal.type.ts +++ b/src/queue/consumers/scicat/scicatProposal/type/scicatProposal.type.ts @@ -50,7 +50,7 @@ export type UpdateScicatProposalDto = { export type CreateScicatSampleDto = { proposalId: string; - description: string; + sampleName: string; ownerGroup: string; accessGroups?: string[]; isPublished?: boolean; @@ -59,7 +59,7 @@ export type CreateScicatSampleDto = { export type UpdateScicatSampleDto = { sampleId?: string; - description?: string; + sampleName?: string; proposalId?: string; ownerGroup?: string; accessGroups?: string[]; From d2436d484dd97d468ab671203da28822fc427c3a Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 21 May 2026 17:59:38 +0200 Subject: [PATCH 13/16] add minimum scicatApi unit test + fix tests --- src/index.spec.ts | 5 ++++ .../uoToScicatExperiment.mapper.spec.ts | 6 +++++ .../scicatProposal/utils/scicatApi.spec.ts | 25 +++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 src/queue/consumers/scicat/scicatProposal/utils/scicatApi.spec.ts 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/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.spec.ts b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.spec.ts index 7fd75438..f50ed7fe 100644 --- a/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.spec.ts +++ b/src/queue/consumers/scicat/scicatProposal/mappers/uoToScicatExperiment.mapper.spec.ts @@ -1,3 +1,9 @@ +jest.mock('../utils/scicatApi', () => ({ + scicatApi: { + serviceUsername: 'testuser', + }, +})); + import { getCreateScicatExperimentDto, getUpdateScicatExperimentDto, 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; + }); +}); From bec41e1d61bcf92c7d0263e9b524b7e6c7365531 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Fri, 22 May 2026 09:30:29 +0200 Subject: [PATCH 14/16] update .env.tests with new variables --- .env.tests | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.env.tests b/.env.tests index 5312f0ac..b335d6f4 100644 --- a/.env.tests +++ b/.env.tests @@ -10,9 +10,7 @@ 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 @@ -26,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 From 6ad529fda43fc9187b7461858859c7cefc071192 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Fri, 22 May 2026 10:17:02 +0200 Subject: [PATCH 15/16] use correct types for scicatAPI request --- .../scicat/scicatProposal/utils/scicatApi.ts | 99 +++++++++++-------- 1 file changed, 56 insertions(+), 43 deletions(-) diff --git a/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts index 1fa13f58..0d357c5f 100644 --- a/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts +++ b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts @@ -15,7 +15,9 @@ import { getUpdateScicatProposalDto, } from '../mappers/uoToScicatProposal.mapper'; import { + CreateScicatProposalDto, CreateScicatSampleDto, + UpdateScicatProposalDto, UpdateScicatSampleDto, } from '../type/scicatProposal.type'; @@ -145,7 +147,7 @@ class ScicatApi { async findSampleByLookup( sampleLookup: string - ): Promise { + ): Promise { const sciCatAccessToken = this.scicatToken; const filter = JSON.stringify({ where: { @@ -155,7 +157,7 @@ class ScicatApi { const url = `${this.baseUrl}/Samples/findOne?filter=${encodeURIComponent(filter)}`; - const response = await this.request(url, { + const response = await this.request(url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${sciCatAccessToken}`, @@ -187,14 +189,17 @@ class ScicatApi { scicatInstrumentIds ); - const createProposalResponse = await this.request(url, { - method: 'POST', - body: JSON.stringify(createProposalDto), - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${sciCatAccessToken}`, - }, - }); + 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, @@ -217,14 +222,17 @@ class ScicatApi { scicatInstrumentIds ); - const updateProposalResponse = await this.request(url, { - method: 'PATCH', - body: JSON.stringify(updateProposalDto), - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${sciCatAccessToken}`, - }, - }); + 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, @@ -247,14 +255,15 @@ class ScicatApi { scicatInstrumentIds ); - const createExperimentResponse = await this.request(url, { - method: 'POST', - body: JSON.stringify(createExperimentDto), - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${sciCatAccessToken}`, - }, - }); + 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', { @@ -278,14 +287,15 @@ class ScicatApi { scicatInstrumentIds ); - const updateExperimentResponse = await this.request(url, { - method: 'PATCH', - body: JSON.stringify(updateExperimentDto), - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${sciCatAccessToken}`, - }, - }); + 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', { @@ -299,14 +309,17 @@ class ScicatApi { 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}`, - }, - }); + 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, @@ -322,7 +335,7 @@ class ScicatApi { const url = `${this.baseUrl}/Samples/${sampleId}`; - await this.request(url, { + await this.request(url, { method: 'PATCH', body: JSON.stringify(dto), headers: { From ef9f09042bc8a439cc592db8edb63b981b9b220c Mon Sep 17 00:00:00 2001 From: junjiequan Date: Fri, 22 May 2026 10:18:50 +0200 Subject: [PATCH 16/16] Fix wrong type --- src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts index 0d357c5f..ec4e6a01 100644 --- a/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts +++ b/src/queue/consumers/scicat/scicatProposal/utils/scicatApi.ts @@ -147,7 +147,7 @@ class ScicatApi { async findSampleByLookup( sampleLookup: string - ): Promise { + ): Promise { const sciCatAccessToken = this.scicatToken; const filter = JSON.stringify({ where: { @@ -157,7 +157,7 @@ class ScicatApi { const url = `${this.baseUrl}/Samples/findOne?filter=${encodeURIComponent(filter)}`; - const response = await this.request(url, { + const response = await this.request(url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${sciCatAccessToken}`,