@overture-stack/lyric
Version:
Data Submission system
440 lines (439 loc) • 20.9 kB
JavaScript
import * as _ from 'lodash-es';
import createSubmissionRepository from '../../repository/activeSubmissionRepository.js';
import createCategoryRepository from '../../repository/categoryRepository.js';
import { getSchemaByName } from '../../utils/dictionaryUtils.js';
import { BadRequest, InternalServerError, StatusConflict } from '../../utils/errors.js';
import { filterAndPaginateSubmissionData } from '../../utils/submissionResponseParser.js';
import { checkEntityFieldNames, createSubmissionSummaryResponse, isSubmissionActive, removeItemsFromSubmission, resolveFileEntities, } from '../../utils/submissionUtils.js';
import { ACTIVE_SUBMISSION_STATUS, SUBMISSION_ACTION_TYPE, SUBMISSION_STATUS, } from '../../utils/types.js';
import submissionProcessorFactory from './submissionProcessor.js';
const submissionService = (dependencies) => {
const LOG_MODULE = 'SUBMISSION_SERVICE';
const { logger } = dependencies;
const categoryRepository = createCategoryRepository(dependencies);
const submissionProcessor = submissionProcessorFactory.create(dependencies);
const submissionRepository = createSubmissionRepository(dependencies);
/**
* Runs Schema validation asynchronously in a worker thread and moves the Active Submission to Submitted Data
* @param {number} categoryId
* @param {number} submissionId
* @returns {Promise<CommitSubmissionResult>}
*/
const commitSubmission = async (categoryId, submissionId, username) => {
const { getActiveDictionaryByCategory } = categoryRepository;
const submission = await submissionRepository.getSubmissionById(submissionId);
if (!submission) {
throw new BadRequest(`Submission '${submissionId}' not found`);
}
if (submission.dictionaryCategory.id !== categoryId) {
throw new BadRequest(`Category ID provided does not match the category for the Submission`);
}
if (submission.status !== SUBMISSION_STATUS.VALID) {
throw new StatusConflict('Submission does not have status VALID and cannot be committed');
}
const currentDictionary = await getActiveDictionaryByCategory(categoryId);
if (_.isEmpty(currentDictionary)) {
throw new BadRequest(`Dictionary in category '${categoryId}' not found`);
}
await submissionRepository.update(submissionId, { status: SUBMISSION_STATUS.COMMITTING, updatedBy: username });
// Get entities to process
const entitiesToProcess = new Set([
...Object.keys(submission.data?.inserts ?? {}),
...Object.keys(submission.data?.updates ?? {}),
...Object.keys(submission.data?.deletes ?? {}),
]);
// Execute commit submission in worker pool
const commitData = {
submissionId,
username,
};
// Let worker thread run async
dependencies.workerPool.commitSubmission(commitData);
return {
status: ACTIVE_SUBMISSION_STATUS.PROCESSING,
dictionary: {
name: currentDictionary.name,
version: currentDictionary.version,
},
processedEntities: Array.from(entitiesToProcess.values()),
};
};
/**
* Updates Submission status to CLOSED
* This action is allowed only if current Submission Status as OPEN, VALID or INVALID
* Returns the resulting ID of the Submission
* @param {number} submissionId
* @param {string} username
* @param {boolean} force - Flag to force deletion of a submission even if it's not active
* @returns {Promise<DeleteSubmissionResult>}
*/
const deleteActiveSubmissionById = async (submissionId, username, force) => {
const submission = await submissionRepository.getSubmissionById(submissionId);
if (!submission) {
throw new BadRequest(`Submission '${submissionId}' not found`);
}
if (!isSubmissionActive(submission.status) && !force) {
throw new StatusConflict('Submission is not active. Only Active Submission can be deleted');
}
const updatedRecordId = await submissionRepository.update(submission.id, {
status: SUBMISSION_STATUS.CLOSED,
updatedBy: username,
});
logger.info(LOG_MODULE, `Submission '${submissionId}' updated with new status '${SUBMISSION_STATUS.CLOSED}'`);
return {
status: SUBMISSION_STATUS.CLOSED,
description: 'Submission closed successfully',
submissionId: updatedRecordId,
};
};
/**
* Function to remove an entity from an Active Submission by given Submission ID
* It validates resulting Active Submission running cross schema validation along with the existing Submitted Data
* Returns the resulting ID of the Active Submission
* @param {number} submissionId
* @param {string} entityName
* @param {string} username
* @returns { Promise<SubmitDataResult>}
*/
const deleteActiveSubmissionEntity = async (submissionId, username, filter) => {
const submission = await submissionRepository.getSubmissionDetailsById(submissionId);
if (!submission) {
throw new BadRequest(`Submission '${submissionId}' not found`);
}
if (!isSubmissionActive(submission.status)) {
throw new StatusConflict('Submission is not active. Only Active Submission can be modified');
}
if (SUBMISSION_ACTION_TYPE.Values.INSERTS.includes(filter.actionType) &&
!_.has(submission.data.inserts, filter.entityName)) {
throw new BadRequest(`Entity '${filter.entityName}' not found on '${filter.actionType}' Submission`);
}
if (SUBMISSION_ACTION_TYPE.Values.UPDATES.includes(filter.actionType) &&
!_.has(submission.data.updates, filter.entityName)) {
throw new BadRequest(`Entity '${filter.entityName}' not found on '${filter.actionType}' Submission`);
}
if (SUBMISSION_ACTION_TYPE.Values.DELETES.includes(filter.actionType) &&
!_.has(submission.data.deletes, filter.entityName)) {
throw new BadRequest(`Entity '${filter.entityName}' not found on '${filter.actionType}' Submission`);
}
// Remove entity from the Submission
const updatedActiveSubmissionData = removeItemsFromSubmission(submission.data, {
...filter,
});
// Updating the Submission with the new data and 'VALIDATING' status before validation starts
await submissionRepository.update(submission.id, {
data: updatedActiveSubmissionData,
updatedBy: username,
status: 'VALIDATING',
});
// Perform Schema Data validation in a worker thread
dependencies.workerPool.dataValidation({ submissionId: submission.id });
logger.info(LOG_MODULE, `Submission '${submission.id}' updated after removing entity '${filter.entityName}'`);
return {
status: ACTIVE_SUBMISSION_STATUS.PROCESSING,
description: 'Submission records are being processed',
submissionId: submission.id,
};
};
/**
* Get Submissions by Category
* @param {number} categoryId - The ID of the category for which data is being fetched.
* @param {Object} paginationOptions - Pagination properties
* @param {number} paginationOptions.page - Page number
* @param {number} paginationOptions.pageSize - Items per page
* @param {Object} filterOptions
* @param {boolean} filterOptions.onlyActive - Filter by Active status
* @param {string} filterOptions.username - User Name
* @returns an array of Submission
*/
const getSubmissionsByCategory = async (categoryId, paginationOptions, filterOptions) => {
const recordsPaginated = await submissionRepository.getSubmissionsByCategory(categoryId, paginationOptions, filterOptions);
if (!recordsPaginated || recordsPaginated.length === 0) {
return {
result: [],
metadata: {
totalRecords: 0,
},
};
}
const totalRecords = await submissionRepository.getTotalSubmissionsByCategory(categoryId, filterOptions);
return {
metadata: {
totalRecords,
},
result: recordsPaginated.map((response) => createSubmissionSummaryResponse(response)),
};
};
/**
* Get Submission by Submission ID
* @param {number} submissionId A Submission ID
* @returns One Submission
*/
const getSubmissionById = async (submissionId) => {
const submission = await submissionRepository.getSubmissionById(submissionId);
if (_.isEmpty(submission)) {
return;
}
return createSubmissionSummaryResponse(submission);
};
/**
* Get Submission Records paginated
* @param {number} submissionId A Submission ID
* @param {Object} paginationOptions - Pagination properties
* @param {number} paginationOptions.page - Page number
* @param {number} paginationOptions.pageSize - Items per page
* @param {Object} filterOptions
* @param {string} filterOptions.entityName - Filter by Entity name
* @param {string} filterOptions.actionType - Filter by Action type
* @returns One Submission
*/
const getSubmissionDetailsById = async ({ submissionId, paginationOptions, filterOptions, }) => {
const submission = await submissionRepository.getSubmissionDetailsById(submissionId);
if (!submission) {
throw new BadRequest(`Submission '${submissionId}' not found`);
}
const submissionEntityNames = [
...Object.keys(submission.data.inserts ?? {}),
...Object.keys(submission.data.updates ?? {}),
...Object.keys(submission.data.deletes ?? {}),
];
const missingEntityNames = filterOptions.entityNames.filter((name) => !submissionEntityNames.includes(name));
if (filterOptions.entityNames.length > 0 && missingEntityNames.length > 0) {
throw new BadRequest(`Invalid entity name(s) '${missingEntityNames.join(', ')}' for Submission '${submissionId}'`);
}
return filterAndPaginateSubmissionData({
data: submission.data,
errors: submission.errors || {},
filterOptions,
paginationOptions,
});
};
/**
* Get an active Submission by Organization
* @param {Object} params
* @param {number} params.categoryId
* @param {string} params.username
* @param {string} params.organization
* @returns One Active Submission
*/
const getActiveSubmissionByOrganization = async ({ categoryId, username, organization, }) => {
const submission = await submissionRepository.getActiveSubmissionSummary({
organization,
username,
categoryId,
});
if (_.isEmpty(submission)) {
return;
}
return createSubmissionSummaryResponse(submission);
};
/**
* Find the current Active Submission or Create an Open Active Submission with initial values and no schema data.
* Throws an error if the existing active submission is not in a status that can be modified (OPEN, VALID or INVALID)
* @param {object} params
* @param {string} params.username Owner of the Submission
* @param {number} params.categoryId Category ID of the Submission
* @param {string} params.organization Organization name
* @returns number ID of the Active Submission
*/
const getOrCreateActiveSubmission = async (params) => {
const { categoryId, username, organization } = params;
const { getActiveDictionaryByCategory } = categoryRepository;
const activeSubmission = await submissionRepository.getActiveSubmissionSummary({
categoryId,
username,
organization,
});
if (activeSubmission) {
if (!isSubmissionActive(activeSubmission.status)) {
throw new StatusConflict(`Existing submission with status '${activeSubmission.status}' cannot be modified`);
}
return activeSubmission.id;
}
const currentDictionary = await getActiveDictionaryByCategory(categoryId);
if (!currentDictionary) {
throw new InternalServerError(`Dictionary in category '${categoryId}' not found`);
}
const newSubmissionInput = {
createdBy: username,
data: {},
dictionaryCategoryId: categoryId,
dictionaryId: currentDictionary.id,
errors: {},
organization: organization,
status: SUBMISSION_STATUS.OPEN,
};
return submissionRepository.save(newSubmissionInput);
};
/**
* Validates and Creates the Entities Schemas of the Active Submission and stores it in the database
* @param {object} params
* @param {Record<string, unknown>[]} params.records An array of records
* @param {string} params.entityName Entity Name of the Records
* @param {number} params.categoryId Category ID of the Submission
* @param {string} params.organization Organization name
* @param {string} params.username User name creating the Submission
* @returns The Active Submission created or Updated
*/
const submit = async ({ data, categoryId, organization, username, }) => {
const entityNames = Object.keys(data);
logger.info(LOG_MODULE, `Processing '${entityNames.length}' entities on category id '${categoryId}' organization '${organization}'`);
if (entityNames.length === 0) {
return {
status: ACTIVE_SUBMISSION_STATUS.INVALID_SUBMISSION,
description: 'No valid data for submission',
};
}
const currentDictionary = await categoryRepository.getActiveDictionaryByCategory(categoryId);
if (_.isEmpty(currentDictionary)) {
return {
status: 'UNKNOWN_CATEGORY',
description: `Category '${categoryId}' is not available: either this is an invalid ID or the category has no Dictionary registered.`,
};
}
const schemasDictionary = {
name: currentDictionary.name,
version: currentDictionary.version,
schemas: currentDictionary.schemas,
};
// Validate entity name
const invalidEntities = entityNames.filter((name) => !getSchemaByName(name, schemasDictionary));
if (invalidEntities.length) {
return {
status: ACTIVE_SUBMISSION_STATUS.INVALID_SUBMISSION,
description: `Invalid entity name '${invalidEntities}' for submission`,
};
}
// Get Active Submission or Open a new one
let activeSubmissionId;
try {
activeSubmissionId = await getOrCreateActiveSubmission({ categoryId, username, organization });
}
catch (error) {
if (error instanceof StatusConflict || error instanceof InternalServerError) {
return {
status: ACTIVE_SUBMISSION_STATUS.INVALID_SUBMISSION,
description: error.message,
};
}
throw error;
}
// Schema validation runs asynchronously and does not block execution.
// The results will be saved to the database.
submissionProcessor.processInsertRecordsAsync({
records: data,
submissionId: activeSubmissionId,
schemasDictionary,
username,
});
return {
status: ACTIVE_SUBMISSION_STATUS.PROCESSING,
description: 'Submission records are being processed',
submissionId: activeSubmissionId,
};
};
/**
* Validates and Creates the Entities Schemas of the Active Submission and stores it in the database
* @param {object} params
* @param {Express.Multer.File[]} params.files An array of files
* @param {number} params.categoryId Category ID of the Submission
* @param {string} params.organization Organization name
* @param {string} params.username User name creating the Submission
* @returns The Active Submission created or Updated
*/
const submitFiles = async ({ files, categoryId, organization, username, fileEntityMap, }) => {
logger.info(LOG_MODULE, `Processing '${files.length}' files on category id '${categoryId}'`);
if (files.length === 0) {
return {
status: ACTIVE_SUBMISSION_STATUS.INVALID_SUBMISSION,
description: 'No valid files for submission',
batchErrors: [],
inProcessEntities: [],
};
}
const currentDictionary = await categoryRepository.getActiveDictionaryByCategory(categoryId);
if (_.isEmpty(currentDictionary)) {
return {
status: 'UNKNOWN_CATEGORY',
description: `Category '${categoryId}' is not available: either this is an invalid ID or the category has no Dictionary registered.`,
};
}
const schemasDictionary = {
name: currentDictionary.name,
version: currentDictionary.version,
schemas: currentDictionary.schemas,
};
// step 1 Validation. Validate entity type (filename matches dictionary entities, remove duplicates)
const { validFileEntity, batchErrors: fileNamesErrors } = await resolveFileEntities(files, schemasDictionary.schemas, fileEntityMap);
if (_.isEmpty(validFileEntity)) {
logger.debug(LOG_MODULE, `No valid files for submission`);
}
// step 2 Validation. Validate fieldNames (missing required fields based on schema)
const { checkedEntities, fieldNameErrors } = await checkEntityFieldNames(validFileEntity);
const batchErrors = [...fileNamesErrors, ...fieldNameErrors];
const entitiesToProcess = Object.keys(checkedEntities);
if (_.isEmpty(checkedEntities)) {
logger.info(LOG_MODULE, 'Found errors on Submission files.', JSON.stringify(batchErrors));
return {
status: ACTIVE_SUBMISSION_STATUS.INVALID_SUBMISSION,
description: 'No valid entities in submission',
batchErrors,
inProcessEntities: entitiesToProcess,
};
}
// Get Active Submission or Open a new one
let activeSubmissionId;
try {
activeSubmissionId = await getOrCreateActiveSubmission({ categoryId, username, organization });
}
catch (error) {
if (error instanceof StatusConflict || error instanceof InternalServerError) {
return {
status: ACTIVE_SUBMISSION_STATUS.INVALID_SUBMISSION,
description: error.message,
batchErrors: [],
inProcessEntities: [],
};
}
throw error;
}
// TODO: Add files to submission, then run validation separately. Currently these processes are both
// done by the function that adds the files to the submission.
// Start background process of adding files to submission
// Running Schema validation in the background do not need to wait
// Result of validations will be stored in database
submissionProcessor.addFilesToSubmissionAsync(checkedEntities, {
categoryId,
organization,
username,
});
if (batchErrors.length === 0) {
return {
status: ACTIVE_SUBMISSION_STATUS.PROCESSING,
description: 'Submission files are being processed',
submissionId: activeSubmissionId,
batchErrors,
inProcessEntities: entitiesToProcess,
};
}
return {
status: ACTIVE_SUBMISSION_STATUS.PARTIAL_SUBMISSION,
description: 'Some Submission files are being processed while others were unable to process',
submissionId: activeSubmissionId,
batchErrors,
inProcessEntities: entitiesToProcess,
};
};
return {
commitSubmission,
deleteActiveSubmissionById,
deleteActiveSubmissionEntity,
getSubmissionsByCategory,
getSubmissionById,
getSubmissionDetailsById,
getActiveSubmissionByOrganization,
getOrCreateActiveSubmission,
submit,
submitFiles,
};
};
export default submissionService;