diff --git a/package.json b/package.json index 118f460a..7d6329bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hpc-api", - "version": "4.7.1", + "version": "4.8.0", "description": "api for HPC applications", "main": "src/server.ts", "license": "MIT", @@ -17,14 +17,14 @@ "lint": "yarn lint-prettier && yarn lint-eslint" }, "dependencies": { - "@unocha/hpc-api-core": "^10.1.1", + "@unocha/hpc-api-core": "^10.6.0", "apollo-server-hapi": "^3.13.0", "bunyan": "^1.8.15", "class-validator": "^0.14.1", "graphql": "^15.9.0", "knex": "3.1.0", - "pg": "^8.12.0", - "pm2": "^5.4.2", + "pg": "^8.13.1", + "pm2": "^5.4.3", "reflect-metadata": "^0.2.2", "ts-node": "^10.9.2", "type-graphql": "^1.1.1", @@ -35,15 +35,15 @@ "@hapi/hapi": "^20.3.0", "@types/bunyan": "^1.8.11", "@types/hapi__hapi": "^20.0.9", - "@types/jest": "^29.5.13", - "@types/node": "^22.7.5", + "@types/jest": "^29.5.14", + "@types/node": "^22.10.1", "@types/pg": "^8.11.10", "@unocha/hpc-repo-tools": "^5.0.0", "eslint": "9.9.1", - "husky": "^9.1.6", + "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^15.2.10", - "prettier": "3.3.3", + "prettier": "3.4.1", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0" }, diff --git a/src/domain-services/base-types.ts b/src/domain-services/base-types.ts index 24db78d8..3febd535 100644 --- a/src/domain-services/base-types.ts +++ b/src/domain-services/base-types.ts @@ -1,16 +1,23 @@ import { Field, ObjectType } from 'type-graphql'; +export type EntityDirection = 'source' | 'destination'; @ObjectType() export class BaseType { @Field() - createdAt: Date; + createdAt: string; @Field() - updatedAt: Date; + updatedAt: string; +} + +@ObjectType() +export class BaseTypeWithDirection extends BaseType { + @Field() + direction: EntityDirection; } @ObjectType() export class BaseTypeWithSoftDelete extends BaseType { - @Field({ nullable: true }) - deletedAt: Date; + @Field(() => String, { nullable: true }) + deletedAt: string | null; } diff --git a/src/domain-services/categories/category-service.ts b/src/domain-services/categories/category-service.ts new file mode 100644 index 00000000..7c9abc23 --- /dev/null +++ b/src/domain-services/categories/category-service.ts @@ -0,0 +1,267 @@ +import { type Database } from '@unocha/hpc-api-core/src/db'; +import { type FlowId } from '@unocha/hpc-api-core/src/db/models/flow'; +import { + Cond, + Op, + type Condition, +} from '@unocha/hpc-api-core/src/db/util/conditions'; +import { type InstanceOfModel } from '@unocha/hpc-api-core/src/db/util/types'; +import { getOrCreate } from '@unocha/hpc-api-core/src/util'; +import { Service } from 'typedi'; +import { type ReportDetail } from '../report-details/graphql/types'; +import { type Category } from './graphql/types'; +import { type ShortcutCategoryFilter } from './model'; + +// Local types definition to increase readability +type CategoryRefModel = Database['categoryRef']; +type CategoryRefInstance = InstanceOfModel; + +type CategoryModel = Database['category']; +type CategoryInstance = InstanceOfModel; +type CategoryWhere = Condition; + +@Service() +export class CategoryService { + async getCategoriesForFlows( + flowWithVersion: Map, + models: Database + ): Promise>> { + // Group of flowIDs and its versions + // Structure: + // flowID: { + // versionID: [categories] + // } + const flowVersionCategoryMap = new Map>(); + + const flowIDs: FlowId[] = []; + for (const flowID of flowWithVersion.keys()) { + flowIDs.push(flowID); + } + + const categoriesRef: CategoryRefInstance[] = await models.categoryRef.find({ + where: { + objectID: { + [Op.IN]: flowIDs, + }, + objectType: 'flow', + }, + }); + + const categories: CategoryInstance[] = await models.category.find({ + where: { + id: { + [Op.IN]: categoriesRef.map((catRef) => catRef.categoryID), + }, + }, + }); + + // Populate the map with categories for each flow + for (const catRef of categoriesRef) { + const flowId = catRef.objectID.valueOf(); + + if (!flowVersionCategoryMap.has(flowId)) { + flowVersionCategoryMap.set(flowId, new Map()); + } + + // Here the key is the versionID of the flow + const flowVersionMap = getOrCreate( + flowVersionCategoryMap, + flowId, + () => new Map() + ); + + const flowVersion = catRef.versionID; + if (!flowVersionMap.has(flowVersion)) { + flowVersionMap.set(flowVersion, []); + } + + const categoriesPerFlowVersion = getOrCreate( + flowVersionMap, + flowVersion, + () => [] + ); + + const category = categories.find((cat) => cat.id === catRef.categoryID); + + if ( + category && + !categoriesPerFlowVersion.some( + (cat) => cat.id === category.id.valueOf() + ) + ) { + const mappedCategory = this.mapCategoryToFlowCategory(category, catRef); + categoriesPerFlowVersion.push(mappedCategory); + } + } + + return flowVersionCategoryMap; + } + + private mapCategoryToFlowCategory( + category: CategoryInstance, + categoryRef: CategoryRefInstance + ): Category { + return { + id: category.id, + name: category.name, + group: category.group, + createdAt: category.createdAt.toISOString(), + updatedAt: category.updatedAt.toISOString(), + description: category.description ?? '', + parentID: category.parentID ? category.parentID.valueOf() : null, + code: category.code ?? '', + includeTotals: category.includeTotals ?? false, + categoryRef: { + objectID: categoryRef.objectID.valueOf(), + versionID: categoryRef.versionID, + objectType: categoryRef.objectType, + categoryID: category.id.valueOf(), + createdAt: categoryRef.createdAt.toISOString(), + updatedAt: categoryRef.updatedAt.toISOString(), + }, + versionID: categoryRef.versionID, + }; + } + + async addChannelToReportDetails( + models: Database, + reportDetails: ReportDetail[] + ): Promise { + const listOfCategoryRefORs: Array> = []; + + for (const reportDetail of reportDetails) { + const orClause = { + objectID: reportDetail.id, + objectType: 'reportDetail', + } satisfies Condition; + + listOfCategoryRefORs.push(orClause); + } + + const categoriesRef: CategoryRefInstance[] = await models.categoryRef.find({ + where: { + [Cond.OR]: listOfCategoryRefORs, + }, + }); + + const mapOfCategoriesAndReportDetails = new Map(); + + for (const categoryRef of categoriesRef) { + const reportDetail = reportDetails.find( + (reportDetail) => reportDetail.id === categoryRef.objectID.valueOf() + ); + + if (!reportDetail) { + continue; + } + + if ( + !mapOfCategoriesAndReportDetails.has(categoryRef.categoryID.valueOf()) + ) { + mapOfCategoriesAndReportDetails.set( + categoryRef.categoryID.valueOf(), + [] + ); + } + + const reportDetailsPerCategory = getOrCreate( + mapOfCategoriesAndReportDetails, + categoryRef.categoryID.valueOf(), + () => [] + ); + reportDetailsPerCategory.push(reportDetail); + } + + const categories: CategoryInstance[] = await models.category.find({ + where: { + id: { + [Op.IN]: categoriesRef.map((catRef) => catRef.categoryID), + }, + }, + }); + + for (const [ + category, + reportDetails, + ] of mapOfCategoriesAndReportDetails.entries()) { + const categoryObj = categories.find((cat) => cat.id === category); + + if (!categoryObj) { + continue; + } + + for (const reportDetail of reportDetails) { + reportDetail.channel = categoryObj.name; + } + } + + return reportDetails; + } + + /** + * This method returns the shortcut filter defined with the operation + * IN if is true or NOT IN if is false + * + * @param isPendingFlows + * @param isCommitmentFlows + * @param isPaidFlows + * @param isPledgedFlows + * @param isCarryoverFlows + * @param isParkedFlows + * @param isPassThroughFlows + * @param isStandardFlows + * @returns [{ category: String, operation: Op.IN | Op.NOT_IN}] + */ + async mapShortcutFilters( + models: Database, + isPendingFlows: boolean, + isCommitmentFlows: boolean, + isPaidFlows: boolean, + isPledgedFlows: boolean, + isCarryoverFlows: boolean, + isParkedFlows: boolean, + isPassThroughFlows: boolean, + isStandardFlows: boolean + ): Promise { + const filters = [ + { flag: isPendingFlows, category: 'Pending' }, + { flag: isCommitmentFlows, category: 'Commitment' }, + { flag: isPaidFlows, category: 'Paid' }, + { flag: isPledgedFlows, category: 'Pledge' }, + { flag: isCarryoverFlows, category: 'Carryover' }, + { flag: isParkedFlows, category: 'Parked' }, + { flag: isPassThroughFlows, category: 'Pass Through' }, + { flag: isStandardFlows, category: 'Standard' }, + ]; + + const usedFilters = filters.filter((filter) => filter.flag !== undefined); + + const searchCategories = usedFilters.map((filter) => filter.category); + + const whereClause: CategoryWhere = { + [Cond.OR]: searchCategories.map((cat) => ({ + name: { [Op.ILIKE]: `%${cat}%` }, + })), + }; + + const categories = await models.category.find({ + where: whereClause, + }); + + const shortcutFilters: ShortcutCategoryFilter[] = usedFilters + .map((filter) => { + const categoryId = categories + .find((category) => category.name.includes(filter.category)) + ?.id.valueOf(); + + return { + category: filter.category, + operation: filter.flag ? Op.IN : Op.NOT_IN, + id: categoryId, + } satisfies ShortcutCategoryFilter; + }) + .filter((filter) => filter.id !== undefined); + + return shortcutFilters.length > 0 ? shortcutFilters : null; + } +} diff --git a/src/domain-services/categories/graphql/types.ts b/src/domain-services/categories/graphql/types.ts new file mode 100644 index 00000000..70b6aacd --- /dev/null +++ b/src/domain-services/categories/graphql/types.ts @@ -0,0 +1,47 @@ +import { Field, Int, ObjectType } from 'type-graphql'; +import { BaseType } from '../../base-types'; + +@ObjectType() +export class CategoryRef extends BaseType { + @Field({ nullable: false }) + objectID: number; + + @Field({ nullable: false }) + versionID: number; + + @Field({ nullable: false }) + objectType: string; + + @Field({ nullable: false }) + categoryID: number; +} + +@ObjectType() +export class Category extends BaseType { + @Field({ nullable: true }) + id: number; + + @Field({ nullable: false }) + name: string; + + @Field({ nullable: false }) + group: string; + + @Field({ nullable: true }) + description: string; + + @Field(() => Int, { nullable: true }) + parentID: number | null; + + @Field({ nullable: true }) + code: string; + + @Field({ nullable: true }) + includeTotals: boolean; + + @Field(() => CategoryRef, { nullable: true }) + categoryRef: CategoryRef; + + @Field({ nullable: false }) + versionID: number; +} diff --git a/src/domain-services/categories/model.ts b/src/domain-services/categories/model.ts new file mode 100644 index 00000000..e008d0ed --- /dev/null +++ b/src/domain-services/categories/model.ts @@ -0,0 +1,7 @@ +import { type Op } from '@unocha/hpc-api-core/src/db/util/conditions'; + +export type ShortcutCategoryFilter = { + category: string; + operation: typeof Op.IN | typeof Op.NOT_IN; + id?: number; +}; diff --git a/src/domain-services/external-reference/external-reference-service.ts b/src/domain-services/external-reference/external-reference-service.ts new file mode 100644 index 00000000..6896765e --- /dev/null +++ b/src/domain-services/external-reference/external-reference-service.ts @@ -0,0 +1,95 @@ +import { type Database } from '@unocha/hpc-api-core/src/db'; +import { type FlowId } from '@unocha/hpc-api-core/src/db/models/flow'; +import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { type InstanceDataOfModel } from '@unocha/hpc-api-core/src/db/util/raw-model'; +import { type InstanceOfModel } from '@unocha/hpc-api-core/src/db/util/types'; +import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; +import { Service } from 'typedi'; +import { type FlowExternalReference } from '../flows/graphql/types'; +import { type UniqueFlowEntity } from '../flows/model'; +import { type SystemID } from '../report-details/graphql/types'; + +@Service() +export class ExternalReferenceService { + async getExternalReferencesForFlows(flowIDs: FlowId[], models: Database) { + const externalReferences = await models.externalReference.find({ + where: { + flowID: { + [Op.IN]: flowIDs, + }, + }, + skipValidation: true, + }); + + const externalReferencesMap = new Map(); + + // First we add all flowIDs to the map + // Since there might be flows without external references + // thus we want to keep them in the map + for (const flowID of flowIDs) { + externalReferencesMap.set(flowID, []); + } + + // Then we add the external references to the map + // Grouping them by flowID + for (const externalReference of externalReferences) { + const flowID = externalReference.flowID; + const externalReferenceMapped = + this.mapExternalReferenceToExternalReferenceFlows(externalReference); + + const references = externalReferencesMap.get(flowID); + // Logicless check to avoid TS error + // This should never happen since we added all flowIDs to the map + if (references) { + references.push(externalReferenceMapped); + } + } + + return externalReferencesMap; + } + + async getUniqueFlowIDsBySystemID( + models: Database, + systemID: SystemID + ): Promise { + const externalRefences: Array< + InstanceDataOfModel + > = await models.externalReference.find({ + where: { + systemID: systemID, + }, + skipValidation: true, + }); + + const flowIDs: UniqueFlowEntity[] = []; + + for (const reference of externalRefences) { + flowIDs.push(this.mapExternalDataToUniqueFlowEntity(reference)); + } + + return flowIDs; + } + + private mapExternalReferenceToExternalReferenceFlows( + externalReference: InstanceOfModel + ): FlowExternalReference { + return { + systemID: externalReference.systemID, + flowID: externalReference.flowID, + externalRecordID: externalReference.externalRecordID, + externalRecordDate: externalReference.externalRecordDate.toISOString(), + createdAt: externalReference.createdAt.toISOString(), + updatedAt: externalReference.updatedAt.toISOString(), + versionID: externalReference.versionID ?? 0, + }; + } + + private mapExternalDataToUniqueFlowEntity( + external: InstanceDataOfModel + ): UniqueFlowEntity { + return { + id: createBrandedValue(external.flowID), + versionID: external.versionID, + }; + } +} diff --git a/src/domain-services/flow-link/flow-link-service.ts b/src/domain-services/flow-link/flow-link-service.ts new file mode 100644 index 00000000..f523616d --- /dev/null +++ b/src/domain-services/flow-link/flow-link-service.ts @@ -0,0 +1,39 @@ +import { type FlowId } from '@unocha/hpc-api-core/src/db/models/flow'; +import { type Database } from '@unocha/hpc-api-core/src/db/type'; +import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { type InstanceOfModel } from '@unocha/hpc-api-core/src/db/util/types'; +import { getOrCreate } from '@unocha/hpc-api-core/src/util'; +import { Service } from 'typedi'; + +@Service() +export class FlowLinkService { + async getFlowLinksForFlows( + flowIds: FlowId[], + models: Database + ): Promise>>> { + const flowLinks = await models.flowLink.find({ + where: { + childID: { + [Op.IN]: flowIds, + }, + }, + }); + + // Group flowLinks by flow ID for easy mapping + const flowLinksMap = new Map< + number, + Array> + >(); + + // Populate the map with flowLinks for each flow + for (const flowLink of flowLinks) { + const flowId = flowLink.childID.valueOf(); + + const flowLinksForFlow = getOrCreate(flowLinksMap, flowId, () => []); + + flowLinksForFlow.push(flowLink); + } + + return flowLinksMap; + } +} diff --git a/src/domain-services/flow-object/flow-object-service.ts b/src/domain-services/flow-object/flow-object-service.ts new file mode 100644 index 00000000..da97b58e --- /dev/null +++ b/src/domain-services/flow-object/flow-object-service.ts @@ -0,0 +1,150 @@ +import { type Database } from '@unocha/hpc-api-core/src/db'; +import { type FlowId } from '@unocha/hpc-api-core/src/db/models/flow'; +import { + Op, + type Condition, +} from '@unocha/hpc-api-core/src/db/util/conditions'; +import { type OrderByCond } from '@unocha/hpc-api-core/src/db/util/raw-model'; +import type { + FieldsOfModel, + InstanceOfModel, +} from '@unocha/hpc-api-core/src/db/util/types'; +import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; +import { Service } from 'typedi'; +import { type UniqueFlowEntity } from '../flows/model'; +import { buildSearchFlowsObjectConditions } from '../flows/strategy/impl/utils'; +import { type FlowObjectFilterGrouped } from './model'; +import { buildWhereConditionsForFlowObjectFilters } from './utils'; + +// Local types definition to increase readability +type FlowObjectModel = Database['flowObject']; +type FlowObjectInstance = InstanceOfModel; +export type FlowObjectsFieldsDefinition = FieldsOfModel; +export type FlowObjectOrderByCond = OrderByCond; +export type FlowObjectWhere = Condition; +@Service() +export class FlowObjectService { + // Merge with getFlowsObjectsByFlows + async getFlowIdsFromFlowObjects( + models: Database, + where: FlowObjectWhere + ): Promise { + const flowObjects = await models.flowObject.find({ + where, + }); + // Keep only not duplicated flowIDs + return [...new Set(flowObjects.map((flowObject) => flowObject.flowID))]; + } + + async getFlowFromFlowObjects( + models: Database, + where: FlowObjectWhere + ): Promise { + const flowObjects = await models.flowObject.find({ + where, + }); + // Keep only not duplicated flowIDs + return [ + ...new Set( + flowObjects.map((flowObject) => { + return { + id: createBrandedValue(flowObject.flowID), + versionID: flowObject.versionID, + }; + }) + ), + ]; + } + + async getFlowObjectByFlowId(models: Database, flowIds: FlowId[]) { + return await models.flowObject.find({ + where: { + flowID: { + [Op.IN]: flowIds, + }, + }, + }); + } + + async getFlowObjectsByFlowObjectConditions( + models: Database, + flowObjectFilterGrouped: FlowObjectFilterGrouped + ): Promise { + const whereClause = buildWhereConditionsForFlowObjectFilters( + flowObjectFilterGrouped + ); + + return await models.flowObject.find({ where: whereClause }); + } + + async getFlowsObjectsByFlows( + models: Database, + whereClauses: FlowObjectWhere, + orderBy?: FlowObjectOrderByCond + ): Promise { + const distinctColumns: Array = [ + 'flowID', + 'versionID', + ]; + + if (orderBy) { + distinctColumns.push(orderBy.column); + distinctColumns.reverse(); + } + + const flowsObjects: FlowObjectInstance[] = await models.flowObject.find({ + orderBy, + where: whereClauses, + distinct: distinctColumns, + }); + + return flowsObjects; + } + + async progresiveSearch( + models: Database, + referenceList: UniqueFlowEntity[], + batchSize: number, + offset: number, + stopOnBatchSize: boolean, + responseList: FlowObjectInstance[], + flowObjectsWhere: FlowObjectWhere, + orderBy?: FlowObjectOrderByCond + ): Promise { + const reducedFlows = referenceList.slice(offset, offset + batchSize); + + const whereConditions = buildSearchFlowsObjectConditions( + reducedFlows, + flowObjectsWhere + ); + + const flowObjects = await this.getFlowsObjectsByFlows( + models, + whereConditions, + orderBy + ); + + responseList.push(...flowObjects); + + if ( + (stopOnBatchSize && responseList.length === batchSize) || + reducedFlows.length < batchSize + ) { + return responseList; + } + + // Recursive call to get the next batch of flows + offset += batchSize; + + return this.progresiveSearch( + models, + referenceList, + batchSize, + offset, + stopOnBatchSize, + responseList, + flowObjectsWhere, + orderBy + ); + } +} diff --git a/src/domain-services/flow-object/model.ts b/src/domain-services/flow-object/model.ts new file mode 100644 index 00000000..a5617a01 --- /dev/null +++ b/src/domain-services/flow-object/model.ts @@ -0,0 +1,14 @@ +import { type Database } from '@unocha/hpc-api-core/src/db'; +import { type FLOW_OBJECT_TYPE_TYPE } from '@unocha/hpc-api-core/src/db/models/flowObjectType'; +import { type InstanceOfModel } from '@unocha/hpc-api-core/src/db/util/types'; +import type * as t from 'io-ts'; +import { type EntityDirection } from '../base-types'; + +export type FlowObject = InstanceOfModel; + +export type FlowObjectType = t.TypeOf; + +export type FlowObjectFilterGrouped = Map< + FlowObjectType, + Map +>; diff --git a/src/domain-services/flow-object/utils.ts b/src/domain-services/flow-object/utils.ts new file mode 100644 index 00000000..420875e4 --- /dev/null +++ b/src/domain-services/flow-object/utils.ts @@ -0,0 +1,33 @@ +import { Cond, Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { type FlowObjectWhere } from './flow-object-service'; +import { type FlowObjectFilterGrouped } from './model'; + +/** + * This alg iterates over the flowObjectFilters and creates a join for each flowObjectType + * and refDirection allowing to filter the flowObjects by the flowObjectType and refDirection + * inclusivelly for each + * @param flowObjectFiltersGrouped + * @returns FlowObjectWhere + */ +export function buildWhereConditionsForFlowObjectFilters( + flowObjectFiltersGrouped: FlowObjectFilterGrouped +): FlowObjectWhere { + const ANDConditions = []; + for (const [flowObjectType, group] of flowObjectFiltersGrouped.entries()) { + for (const [direction, ids] of group.entries()) { + const condition = { + [Cond.AND]: [ + { + objectType: flowObjectType, + refDirection: direction, + objectID: { [Op.IN]: ids }, + }, + ], + }; + + ANDConditions.push(condition); + } + } + + return { [Cond.AND]: ANDConditions }; +} diff --git a/src/domain-services/flows/flow-search-service.ts b/src/domain-services/flows/flow-search-service.ts new file mode 100644 index 00000000..94f12e51 --- /dev/null +++ b/src/domain-services/flows/flow-search-service.ts @@ -0,0 +1,552 @@ +import { type FlowId } from '@unocha/hpc-api-core/src/db/models/flow'; +import { type Database } from '@unocha/hpc-api-core/src/db/type'; +import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { getOrCreate } from '@unocha/hpc-api-core/src/util'; +import { Service } from 'typedi'; +import type { BaseTypeWithDirection, EntityDirection } from '../base-types'; +import { CategoryService } from '../categories/category-service'; +import { type Category } from '../categories/graphql/types'; +import { type ShortcutCategoryFilter } from '../categories/model'; +import { ExternalReferenceService } from '../external-reference/external-reference-service'; +import { FlowLinkService } from '../flow-link/flow-link-service'; +import { FlowObjectService } from '../flow-object/flow-object-service'; +import { type FlowObject } from '../flow-object/model'; +import { type BaseLocationWithDirection } from '../location/graphql/types'; +import { LocationService } from '../location/location-service'; +import { type Organization } from '../organizations/graphql/types'; +import { OrganizationService } from '../organizations/organization-service'; +import { type BasePlan } from '../plans/graphql/types'; +import { PlanService } from '../plans/plan-service'; +import { type ReportDetail } from '../report-details/graphql/types'; +import { ReportDetailService } from '../report-details/report-detail-service'; +import { type UsageYear } from '../usage-years/graphql/types'; +import { UsageYearService } from '../usage-years/usage-year-service'; +import { FlowService } from './flow-service'; +import type { + FlowCategory, + FlowObjectFilters, + NestedFlowFilters, + SearchFlowsArgs, + SearchFlowsFilters, +} from './graphql/args'; +import type { + Flow, + FlowExternalReference, + FlowParkedParentSource, + FlowSearchResult, + FlowSearchResultNonPaginated, + FlowSortField, + FlowStatusFilter, +} from './graphql/types'; +import type { FlowInstance, FlowOrderByWithSubEntity } from './model'; +import { type FlowSearchStrategy } from './strategy/flow-search-strategy'; +import { OnlyFlowFiltersStrategy } from './strategy/impl/only-flow-conditions-strategy-impl'; +import { SearchFlowByFiltersStrategy } from './strategy/impl/search-flow-by-filters-strategy-impl'; +import { buildOrderBy } from './strategy/impl/utils'; + +@Service() +export class FlowSearchService { + constructor( + // Strategies + private readonly onlyFlowFiltersStrategy: OnlyFlowFiltersStrategy, + private readonly searchFlowByFiltersStrategy: SearchFlowByFiltersStrategy, + // Services + private readonly organizationService: OrganizationService, + private readonly locationService: LocationService, + private readonly planService: PlanService, + private readonly usageYearService: UsageYearService, + private readonly categoryService: CategoryService, + private readonly flowLinkService: FlowLinkService, + private readonly externalReferenceService: ExternalReferenceService, + private readonly reportDetailService: ReportDetailService, + private readonly flowObjectService: FlowObjectService, + private readonly flowService: FlowService + ) {} + + async search( + models: Database, + filters: SearchFlowsArgs + ): Promise { + const { + limit, + nextPageCursor, + prevPageCursor, + sortField, + sortOrder, + includeChildrenOfParkedFlows: shouldIncludeChildrenOfParkedFlows, + nestedFlowFilters, + status, + } = filters; + + const orderBy: FlowOrderByWithSubEntity = buildOrderBy( + sortField, + sortOrder + ); + + let { flowFilters } = filters; + const { + flowObjectFilters, + flowCategoryFilters, + pending: isPendingFlows, + commitment: isCommitmentFlows, + paid: isPaidFlows, + pledge: isPledgedFlows, + carryover: isCarryoverFlows, + parked: isParkedFlows, + pass_through: isPassThroughFlows, + standard: isStandardFlows, + } = filters; + + // Returns an object like + // { name: 'Pending review', + // operation: 'IN'} [] + const shortcutFilters: ShortcutCategoryFilter[] | null = + await this.categoryService.mapShortcutFilters( + models, + isPendingFlows, + isCommitmentFlows, + isPaidFlows, + isPledgedFlows, + isCarryoverFlows, + isParkedFlows, + isPassThroughFlows, + isStandardFlows + ); + + // If shortcutFilter is defined + // We need to check if 'isPendingFlows' is true + // If so, we need to add the 'activeStatus' filter + // To 'false' by default if there is no status filter applied + if ( + shortcutFilters && + isPendingFlows && + (!flowFilters || flowFilters.activeStatus === undefined) + ) { + flowFilters = flowFilters ?? {}; + flowFilters.activeStatus = false; + } + + // Once we've gathered all the filters, we need to determine the strategy + // to use in order to obtain the flowIDs + const strategy: FlowSearchStrategy = this.determineStrategy( + flowFilters, + flowObjectFilters, + flowCategoryFilters, + nestedFlowFilters, + shortcutFilters, + status, + orderBy + ); + + const offset = nextPageCursor ?? prevPageCursor ?? 0; + + // We add 1 to the limit to check if there is a next page + const searchLimit = limit + 1; + + const { flows, count } = await strategy.search({ + models, + limit: searchLimit, + orderBy, + offset, + flowFilters, + flowObjectFilters, + flowCategoryFilters, + nestedFlowFilters, + // Shortcuts for categories + shortcutFilters, + statusFilter: status, + shouldIncludeChildrenOfParkedFlows, + }); + + // Remove the extra item used to check hasNextPage + const hasNextPage = flows.length > limit; + if (hasNextPage) { + flows.pop(); + } + + const flowIds: FlowId[] = []; + const flowWithVersion: Map = new Map(); + + // Obtain flow IDs and flow version IDs + for (const flow of flows) { + flowIds.push(flow.id); + const flowVersionIDs = getOrCreate(flowWithVersion, flow.id, () => []); + flowVersionIDs.push(flow.versionID); + } + + // Obtain external references and flow objects in parallel + const [externalReferencesMap, flowObjects] = await Promise.all([ + this.externalReferenceService.getExternalReferencesForFlows( + flowIds, + models + ), + this.flowObjectService.getFlowObjectByFlowId(models, flowIds), + ]); + + // Map flow objects to their respective arrays + const organizationsFO: FlowObject[] = []; + const locationsFO: FlowObject[] = []; + const plansFO: FlowObject[] = []; + const usageYearsFO: FlowObject[] = []; + + this.groupByFlowObjectType( + flowObjects, + organizationsFO, + locationsFO, + plansFO, + usageYearsFO + ); + + // Obtain flow links + const flowLinksMap = await this.flowLinkService.getFlowLinksForFlows( + flowIds, + models + ); + + // Perform all nested queries in parallel + const [ + categoriesMap, + organizationsMap, + locationsMap, + plansMap, + usageYearsMap, + reportDetailsMap, + ] = await Promise.all([ + this.categoryService.getCategoriesForFlows(flowWithVersion, models), + this.organizationService.getOrganizationsForFlows( + organizationsFO, + models + ), + this.locationService.getLocationsForFlows(locationsFO, models), + this.planService.getPlansForFlows(plansFO, models), + this.usageYearService.getUsageYearsForFlows(usageYearsFO, models), + this.reportDetailService.getReportDetailsForFlows(flowIds, models), + ]); + + const promises = flows.map(async (flow) => { + const flowLink = getOrCreate(flowLinksMap, flow.id, () => []); + + // Categories Map follows the structure: + // flowID: { versionID: [categories]} + // So we need to get the categories for the flow version + const categories = categoriesMap.get(flow.id) ?? new Map(); + const categoriesByVersion = categories.get(flow.versionID) ?? []; + const organizations = organizationsMap.get(flow.id) ?? []; + const locations = locationsMap.get(flow.id) ?? []; + const plans = plansMap.get(flow.id) ?? []; + const usageYears = usageYearsMap.get(flow.id) ?? []; + const externalReferences = externalReferencesMap.get(flow.id) ?? []; + const reportDetails = reportDetailsMap.get(flow.id) ?? []; + + let reportDetailsWithChannel: ReportDetail[] = []; + if (reportDetails.length > 0) { + reportDetailsWithChannel = + await this.categoryService.addChannelToReportDetails( + models, + reportDetails + ); + } + + let parkedParentSource: FlowParkedParentSource | null = null; + const shouldLookAfterParentSource = + flowLink.length > 0 && shouldIncludeChildrenOfParkedFlows; + + if (shouldLookAfterParentSource) { + parkedParentSource = await this.flowService.getParketParents( + flow, + flowLink, + models + ); + } + + const childIDs: number[] = + flowLinksMap + .get(flow.id) + ?.filter( + (flowLink) => flowLink.parentID === flow.id && flowLink.depth > 0 + ) + .map((flowLink) => flowLink.childID.valueOf()) ?? []; + + const parentIDs: number[] = + flowLinksMap + .get(flow.id) + ?.filter( + (flowLink) => flowLink.childID === flow.id && flowLink.depth > 0 + ) + .map((flowLink) => flowLink.parentID.valueOf()) ?? []; + + const parsedFlow: Flow = this.buildFlowDTO( + flow, + categoriesByVersion, + organizations, + locations, + plans, + usageYears, + childIDs, + parentIDs, + externalReferences, + reportDetailsWithChannel, + parkedParentSource + ); + + return parsedFlow; + }); + const items = await Promise.all(promises); + + return { + flows: items, + hasNextPage: hasNextPage, + hasPreviousPage: nextPageCursor !== undefined, + prevPageCursor: nextPageCursor ? nextPageCursor - limit : 0, + nextPageCursor: hasNextPage + ? nextPageCursor + ? nextPageCursor + limit + : limit + : 0, + pageSize: flows.length, + sortField: `${orderBy.entity}.${orderBy.column}` as FlowSortField, + sortOrder: sortOrder ?? 'desc', + total: count, + }; + } + + determineStrategy( + flowFilters: SearchFlowsFilters, + flowObjectFilters: FlowObjectFilters[], + flowCategoryFilters: FlowCategory[], + nestedFlowFilters: NestedFlowFilters, + shortcutFilters: ShortcutCategoryFilter[] | null, + status?: FlowStatusFilter | null, + orderBy?: FlowOrderByWithSubEntity + ) { + // If there are no filters (flowFilters, flowObjectFilters, flowCategoryFilters, nestedFlowFilters or shortcutFilter) + // and there is no sortByEntity (orderBy.entity === 'flow') + // use onlyFlowFiltersStrategy + // If there are no sortByEntity (orderBy.entity === 'flow') + // but flowFilters only or flowStatusFilter only + // use onlyFlowFiltersStrategy + const isOrderByEntityFlow = + orderBy === undefined || orderBy?.entity === 'flow'; + const isFlowFiltersDefined = flowFilters !== undefined; + const isFlowObjectFiltersDefined = flowObjectFilters !== undefined; + const isFlowCategoryFiltersDefined = flowCategoryFilters !== undefined; + const isNestedFlowFiltersDefined = nestedFlowFilters !== undefined; + // Shortcuts fot categories + const isFilterByShortcutsDefined = shortcutFilters !== null; + const isFilterByFlowStatusDefined = status !== undefined; + + const isNoFilterDefined = + !isFlowFiltersDefined && + !isFlowObjectFiltersDefined && + !isFlowCategoryFiltersDefined && + !isFilterByShortcutsDefined && + !isNestedFlowFiltersDefined && + !isFilterByFlowStatusDefined; + + const isFlowFiltersOnly = + (isFlowFiltersDefined || isFilterByFlowStatusDefined) && + !isFlowObjectFiltersDefined && + !isFlowCategoryFiltersDefined && + !isFilterByShortcutsDefined && + !isNestedFlowFiltersDefined; + + if (isOrderByEntityFlow && (isNoFilterDefined || isFlowFiltersOnly)) { + // Use onlyFlowFiltersStrategy + return this.onlyFlowFiltersStrategy; + } + + // Otherwise, use flowObjectFiltersStrategy + return this.searchFlowByFiltersStrategy; + } + + private groupByFlowObjectType( + flowObjects: FlowObject[], + organizationsFO: FlowObject[], + locationsFO: FlowObject[], + plansFO: FlowObject[], + usageYearsFO: FlowObject[] + ) { + for (const flowObject of flowObjects) { + if (flowObject.objectType === 'organization') { + organizationsFO.push(flowObject); + } else if (flowObject.objectType === 'location') { + locationsFO.push(flowObject); + } else if (flowObject.objectType === 'plan') { + plansFO.push(flowObject); + } else if (flowObject.objectType === 'usageYear') { + usageYearsFO.push(flowObject); + } + } + } + + private buildFlowDTO( + flow: FlowInstance, + categories: Category[], + organizations: Organization[], + locations: BaseLocationWithDirection[], + plans: BasePlan[], + usageYears: UsageYear[], + childIDs: number[], + parentIDs: number[], + externalReferences: FlowExternalReference[], + reportDetails: ReportDetail[], + parkedParentSource: FlowParkedParentSource | null + ): Flow { + return { + // Mandatory fields + id: flow.id.valueOf(), + versionID: flow.versionID, + amountUSD: flow.amountUSD.toString(), + createdAt: flow.createdAt.toISOString(), + updatedAt: flow.updatedAt.toISOString(), + activeStatus: flow.activeStatus, + restricted: flow.restricted, + flowDate: flow.flowDate ? flow.flowDate.toISOString() : null, + decisionDate: flow.decisionDate ? flow.decisionDate.toISOString() : null, + firstReportedDate: flow.firstReportedDate + ? flow.firstReportedDate.toISOString() + : null, + budgetYear: flow.budgetYear, + exchangeRate: flow.exchangeRate ? flow.exchangeRate.toString() : null, + origAmount: flow.origAmount ? flow.origAmount.toString() : null, + origCurrency: flow.origCurrency ? flow.origCurrency.toString() : null, + description: flow.description, + notes: flow.notes, + versionStartDate: flow.versionStartDate + ? flow.versionStartDate.toISOString() + : null, + versionEndDate: flow.versionEndDate + ? flow.versionEndDate.toISOString() + : null, + newMoney: flow.newMoney, + + // Optional fields + categories, + organizations, + locations, + plans, + usageYears, + childIDs, + parentIDs, + + externalReferences, + reportDetails, + parkedParentSource, + + // Separate nested fields by source and destination + // Source + sourceUsageYears: this.mapNestedPropertyByDirection(usageYears, 'source'), + sourceLocations: this.mapNestedPropertyByDirection(locations, 'source'), + sourcePlans: this.mapNestedPropertyByDirection(plans, 'source'), + sourceOrganizations: this.mapNestedPropertyByDirection( + organizations, + 'source' + ), + // Destination + destinationUsageYears: this.mapNestedPropertyByDirection( + usageYears, + 'destination' + ), + destinationLocations: this.mapNestedPropertyByDirection( + locations, + 'destination' + ), + destinationPlans: this.mapNestedPropertyByDirection(plans, 'destination'), + destinationOrganizations: this.mapNestedPropertyByDirection( + organizations, + 'destination' + ), + }; + } + + private mapNestedPropertyByDirection( + nestedProperty: T[], + direction: EntityDirection + ): T[] { + return nestedProperty.filter( + (nestedProperty) => nestedProperty.direction === direction + ); + } + + async searchBatches( + models: Database, + args: SearchFlowsArgs + ): Promise { + // We need to check if the user sent a 'usageYear' FlowObjectFilter + // If not - we need to add it to the filters (both source and destination since 2021 and after) + const { flowObjectFilters } = args; + if (flowObjectFilters) { + const usageYearFilter = flowObjectFilters.find( + (filter) => filter.objectType === 'usageYear' + ); + + if (!usageYearFilter) { + // Find the flowObjectFilters since 2021 until currentYear + let startYear = 2021; + const currentYear = new Date().getFullYear(); + + const usageYearsArrayFilter: string[] = []; + while (startYear <= currentYear) { + usageYearsArrayFilter.push(startYear.toString()); + startYear++; + } + const usageYears = await models.usageYear.find({ + where: { + year: { + [Op.IN]: usageYearsArrayFilter, + }, + }, + }); + + for (const usageYear of usageYears) { + // Map the usageYear filters to the flowObjectFilters + const sourceUsageYearFilter: FlowObjectFilters = { + objectType: 'usageYear', + direction: 'source', + objectID: usageYear.id.valueOf(), + inclusive: true, + }; + + const destinationUsageYearFilter: FlowObjectFilters = { + objectType: 'usageYear', + direction: 'destination', + objectID: usageYear.id.valueOf(), + inclusive: true, + }; + + flowObjectFilters.push( + sourceUsageYearFilter, + destinationUsageYearFilter + ); + } + } + } + + args.flowObjectFilters = flowObjectFilters; + // Default limit to increase performance + args.limit = 5000; + // Do the first search + const flowSearchResponse = await this.search(models, args); + + const flows: Flow[] = flowSearchResponse.flows; + + let hasNextPage = flowSearchResponse.hasNextPage; + + let cursor = flowSearchResponse.nextPageCursor; + let nextArgs: SearchFlowsArgs = { ...args, nextPageCursor: cursor }; + + let nextFlowSearchResponse: FlowSearchResult; + while (hasNextPage) { + nextFlowSearchResponse = await this.search(models, nextArgs); + flows.push(...nextFlowSearchResponse.flows); + + hasNextPage = nextFlowSearchResponse.hasNextPage; + cursor = nextFlowSearchResponse.nextPageCursor; + + // Update the cursor for the next iteration + nextArgs = { ...args, nextPageCursor: cursor }; + } + + return { flows, flowsCount: flows.length }; + } +} diff --git a/src/domain-services/flows/flow-service.ts b/src/domain-services/flows/flow-service.ts new file mode 100644 index 00000000..1e485fd2 --- /dev/null +++ b/src/domain-services/flows/flow-service.ts @@ -0,0 +1,615 @@ +import { type Database } from '@unocha/hpc-api-core/src/db'; +import { type FlowId } from '@unocha/hpc-api-core/src/db/models/flow'; +import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { type InstanceOfModel } from '@unocha/hpc-api-core/src/db/util/types'; +import { + createBrandedValue, + getTableColumns, +} from '@unocha/hpc-api-core/src/util/types'; +import { Service } from 'typedi'; +import { FlowObjectService } from '../flow-object/flow-object-service'; +import type { + FlowObject, + FlowObjectFilterGrouped, + FlowObjectType, +} from '../flow-object/model'; +import { buildWhereConditionsForFlowObjectFilters } from '../flow-object/utils'; +import { type FlowParkedParentSource } from './graphql/types'; +import type { + FlowInstance, + FlowOrderByCond, + FlowOrderByWithSubEntity, + FlowWhere, + IGetFlowsArgs, + UniqueFlowEntity, +} from './model'; +import { buildSearchFlowsConditions } from './strategy/impl/utils'; + +@Service() +export class FlowService { + constructor(private readonly flowObjectService: FlowObjectService) {} + + async getFlows(args: IGetFlowsArgs): Promise { + const { models, orderBy, conditions, limit, offset } = args; + + const distinctColumns: Array = ['id', 'versionID']; + + if (orderBy) { + distinctColumns.push(orderBy.column); + distinctColumns.reverse(); + } + + const flows: FlowInstance[] = await models.flow.find({ + orderBy, + where: conditions, + distinct: distinctColumns, + limit, + offset, + }); + + return flows; + } + + async getFlowIDsFromEntity( + database: Database, + orderBy: FlowOrderByWithSubEntity + ): Promise { + const entity = orderBy.subEntity ?? orderBy.entity; + let columns: string[] = []; + + // Get the entity list + // 'externalReference' is a special case + // because it does have a direct relation with flow + // and no direction + if (entity === 'externalReference') { + columns = getTableColumns(database.externalReference); + if (!columns.includes(orderBy.column)) { + throw new Error( + `Invalid column ${orderBy.column} to sort by in ${orderBy.entity}` + ); + } + + const column = orderBy.column as keyof InstanceOfModel< + Database['externalReference'] + >; + const externalReferences = await database.externalReference.find({ + orderBy: { column, order: orderBy.order }, + distinct: ['flowID', 'versionID'], + }); + + const uniqueFlowEntities: UniqueFlowEntity[] = externalReferences.map( + (externalReference) => + ({ + id: externalReference.flowID, + versionID: externalReference.versionID, + }) satisfies UniqueFlowEntity + ); + + return uniqueFlowEntities; + } + + const refDirection = orderBy.direction ?? 'source'; + + let flowObjects = []; + let entityIDsSorted: number[] = []; + + switch (entity) { + case 'emergency': { + columns = getTableColumns(database.emergency); + if (!columns.includes(orderBy.column)) { + throw new Error( + `Invalid column ${orderBy.column} to sort by in ${orderBy.entity}` + ); + } + + // Get emergency entities sorted + const column = orderBy.column as keyof InstanceOfModel< + Database['emergency'] + >; + + const orderByEmergency = { column, order: orderBy.order }; + + const emergencies = await database.emergency.find({ + distinct: [column, 'id'], + orderBy: orderByEmergency, + }); + + entityIDsSorted = emergencies.map((emergency) => + emergency.id.valueOf() + ); + break; + } + case 'globalCluster': { + columns = getTableColumns(database.globalCluster); + + if (!columns.includes(orderBy.column)) { + throw new Error( + `Invalid column ${orderBy.column} to sort by in ${orderBy.entity}` + ); + } + // Get globalCluster entities sorted + const column = orderBy.column as keyof InstanceOfModel< + Database['globalCluster'] + >; + const orderByGlobalCluster = { column, order: orderBy.order }; + + const globalClusters = await database.globalCluster.find({ + distinct: [column, 'id'], + orderBy: orderByGlobalCluster, + }); + + entityIDsSorted = globalClusters.map((globalCluster) => + globalCluster.id.valueOf() + ); + break; + } + case 'governingEntity': { + columns = getTableColumns(database.governingEntity); + + if (!columns.includes(orderBy.column)) { + throw new Error( + `Invalid column ${orderBy.column} to sort by in ${orderBy.entity}` + ); + } + // Get governingEntity entities sorted + const column = orderBy.column as keyof InstanceOfModel< + Database['governingEntity'] + >; + const orderByGoverningEntity = { column, order: orderBy.order }; + + const governingEntities = await database.governingEntity.find({ + distinct: [column, 'id'], + orderBy: orderByGoverningEntity, + }); + + entityIDsSorted = governingEntities.map((governingEntity) => + governingEntity.id.valueOf() + ); + break; + } + case 'location': { + columns = getTableColumns(database.location); + + if (!columns.includes(orderBy.column)) { + throw new Error( + `Invalid column ${orderBy.column} to sort by in ${orderBy.entity}` + ); + } + // Get location entities sorted + const column = orderBy.column as keyof InstanceOfModel< + Database['location'] + >; + const orderByLocation = { column, order: orderBy.order }; + + const locations = await database.location.find({ + distinct: [column, 'id'], + orderBy: orderByLocation, + }); + + entityIDsSorted = locations.map((location) => location.id.valueOf()); + break; + } + case 'organization': { + columns = getTableColumns(database.organization); + + if (!columns.includes(orderBy.column)) { + throw new Error( + `Invalid column ${orderBy.column} to sort by in ${orderBy.entity}` + ); + } + // Get organization entities sorted + const column = orderBy.column as keyof InstanceOfModel< + Database['organization'] + >; + const orderByOrganization = { column, order: orderBy.order }; + + const organizations = await database.organization.find({ + distinct: [column, 'id'], + orderBy: orderByOrganization, + }); + + entityIDsSorted = organizations.map((organization) => + organization.id.valueOf() + ); + break; + } + case 'plan': { + columns = getTableColumns(database.plan); + + if (!columns.includes(orderBy.column)) { + throw new Error( + `Invalid column ${orderBy.column} to sort by in ${orderBy.entity}` + ); + } + // Get plan entities sorted + const column = orderBy.column as keyof InstanceOfModel< + Database['plan'] + >; + const orderByPlan = { column, order: orderBy.order }; + + const plans = await database.plan.find({ + distinct: [column, 'id'], + orderBy: orderByPlan, + }); + + entityIDsSorted = plans.map((plan) => plan.id.valueOf()); + break; + } + case 'project': { + columns = getTableColumns(database.project); + + if (!columns.includes(orderBy.column)) { + throw new Error( + `Invalid column ${orderBy.column} to sort by in ${orderBy.entity}` + ); + } + // Get project entities sorted + const column = orderBy.column as keyof InstanceOfModel< + Database['project'] + >; + const orderByProject = { column, order: orderBy.order }; + + const projects = await database.project.find({ + distinct: [column, 'id'], + orderBy: orderByProject, + }); + + entityIDsSorted = projects.map((project) => project.id.valueOf()); + break; + } + case 'usageYear': { + columns = getTableColumns(database.usageYear); + + if (!columns.includes(orderBy.column)) { + throw new Error( + `Invalid column ${orderBy.column} to sort by in ${orderBy.entity}` + ); + } + // Get usageYear entities sorted + const column = orderBy.column as keyof InstanceOfModel< + Database['usageYear'] + >; + const orderByUsageYear = { column, order: orderBy.order }; + + const usageYears = await database.usageYear.find({ + distinct: [column, 'id'], + orderBy: orderByUsageYear, + }); + + entityIDsSorted = usageYears.map((usageYear) => usageYear.id.valueOf()); + break; + } + case 'planVersion': { + columns = getTableColumns(database.planVersion); + + if (!columns.includes(orderBy.column)) { + throw new Error( + `Invalid column ${orderBy.column} to sort by in ${orderBy.entity}` + ); + } + // Get planVersion entities sorted + // Collect fisrt part of the entity key by the fisrt Case letter + const entityKey = `${ + entity.split(/[A-Z]/)[0] + }Id` as keyof InstanceOfModel; + + const column = orderBy.column as keyof InstanceOfModel< + Database['planVersion'] + >; + const orderByPlanVersion = { column, order: orderBy.order }; + + const planVersions = await database.planVersion.find({ + distinct: [column, entityKey], + orderBy: orderByPlanVersion, + }); + + entityIDsSorted = planVersions.map((planVersion) => + planVersion.planId.valueOf() + ); + break; + } + default: { + throw new Error(`Invalid entity ${orderBy.entity} to sort by`); + } + } + + // After getting the sorted entityID list + // we can now get the flowObjects + const entityCondKey = orderBy.entity as unknown; + const entityCondKeyFlowObjectType = entityCondKey as FlowObjectType; + + flowObjects = await database.flowObject.find({ + where: { + objectType: entityCondKeyFlowObjectType, + refDirection, + objectID: { + [Op.IN]: entityIDsSorted, + }, + }, + distinct: ['flowID', 'versionID'], + }); + + // Then, we need to filter the results from the flowObject table + // using the planVersions list as sorted reference + // this is because we cannot apply the order of a given list + // to the query directly + flowObjects = flowObjects + .map((flowObject) => ({ + ...flowObject, + sortingKey: entityIDsSorted.indexOf(flowObject.objectID.valueOf()), + })) + .sort((a, b) => a.sortingKey - b.sortingKey); + + return this.mapFlowsToUniqueFlowEntities(flowObjects); + } + + private mapFlowsToUniqueFlowEntities( + flowObjects: FlowObject[] + ): UniqueFlowEntity[] { + return flowObjects.map( + (flowObject) => + ({ + id: flowObject.flowID, + versionID: flowObject.versionID, + }) satisfies UniqueFlowEntity + ); + } + + async getParketParents( + flow: FlowInstance, + flowLinkArray: Array>, + models: Database + ): Promise { + const flowLinksParentsIDs = flowLinkArray + .filter( + (flowLink) => + flowLink.parentID !== flow.id && flowLink.childID === flow.id + ) + .map((flowLink) => flowLink.parentID.valueOf()); + + if (flowLinksParentsIDs.length === 0) { + return null; + } + + const parkedCategory = await models.category.findOne({ + where: { + group: 'flowType', + name: 'Parked', + }, + }); + + const parentFlows: number[] = []; + + for (const flowLinkParentID of flowLinksParentsIDs) { + const parkedParentCategoryRef = await models.categoryRef.find({ + where: { + categoryID: parkedCategory?.id, + versionID: flow.versionID, + objectID: flowLinkParentID, + objectType: 'flow', + }, + }); + + if (parkedParentCategoryRef && parkedParentCategoryRef.length > 0) { + parentFlows.push(flowLinkParentID); + } + } + + const parkedParentFlowObjectsOrganizationSource: FlowObject[] = []; + + for (const parentFlow of parentFlows) { + const parkedParentOrganizationFlowObject = + await models.flowObject.findOne({ + where: { + flowID: createBrandedValue(parentFlow), + objectType: 'organization', + refDirection: 'source', + versionID: flow.versionID, + }, + }); + + if (parkedParentOrganizationFlowObject) { + parkedParentFlowObjectsOrganizationSource.push( + parkedParentOrganizationFlowObject + ); + } + } + + const parkedParentOrganizations = await models.organization.find({ + where: { + id: { + [Op.IN]: parkedParentFlowObjectsOrganizationSource.map((flowObject) => + createBrandedValue(flowObject?.objectID) + ), + }, + }, + }); + + const mappedParkedParentOrganizations: FlowParkedParentSource = { + organization: [], + orgName: [], + abbreviation: [], + }; + + for (const parkedParentOrganization of parkedParentOrganizations) { + mappedParkedParentOrganizations.organization.push( + parkedParentOrganization.id.valueOf() + ); + mappedParkedParentOrganizations.orgName.push( + parkedParentOrganization.name + ); + mappedParkedParentOrganizations.abbreviation.push( + parkedParentOrganization.abbreviation ?? '' + ); + } + + return mappedParkedParentOrganizations; + } + + async getParkedParentFlowsByFlowObjectFilter( + models: Database, + flowObjectFilters: FlowObjectFilterGrouped + ): Promise { + const parkedCategory = await models.category.findOne({ + where: { + name: 'Parked', + group: 'flowType', + }, + }); + + if (!parkedCategory) { + throw new Error('Parked category not found'); + } + + const categoryRefs = await models.categoryRef.find({ + where: { + categoryID: parkedCategory.id, + objectType: 'flow', + }, + distinct: ['objectID', 'versionID'], + }); + + const flowLinks = await models.flowLink.find({ + where: { + depth: { + [Op.GT]: 0, + }, + parentID: { + [Op.IN]: categoryRefs.map((categoryRef) => + createBrandedValue(categoryRef.objectID) + ), + }, + }, + distinct: ['parentID', 'childID'], + }); + + const parentFlowsRef: UniqueFlowEntity[] = flowLinks.map((flowLink) => ({ + id: createBrandedValue(flowLink.parentID), + versionID: null, + })); + + // Since this list can be really large in size: ~42k flow links + // This can cause a performance issue when querying the database + // and even end up with a error like: + // could not resize shared memory segment \"/PostgreSQL.2154039724\" + // to 53727360 bytes: No space left on device + + // We need to do this query by chunks + const parentFlows = await this.progresiveSearch( + models, + parentFlowsRef, + 1000, + 0, + false, // Stop on batch size + [], + { activeStatus: true } + ); + + const flowObjectsWhere = + buildWhereConditionsForFlowObjectFilters(flowObjectFilters); + + const flowObjects = await this.flowObjectService.getFlowFromFlowObjects( + models, + flowObjectsWhere + ); + + // Once we get the flowObjects - we need to keep only those that are present in both lists + const filteredParentFlows = parentFlows.filter((parentFlow) => + flowObjects.some( + (flowObject) => + flowObject.id === parentFlow.id && + flowObject.versionID === parentFlow.versionID + ) + ); + + // Once we have the ParentFlows whose status are 'parked' + // We keep look for the flowLinks of those flows to obtain the child flows + // that are linked to them + const childFlowsIDs: FlowId[] = []; + for (const flowLink of flowLinks) { + if ( + filteredParentFlows.some( + (parentFlow) => parentFlow.id === flowLink.parentID + ) + ) { + childFlowsIDs.push(flowLink.childID); + } + } + + const childFlows = await models.flow.find({ + where: { + deletedAt: null, + activeStatus: true, + id: { + [Op.IN]: childFlowsIDs, + }, + }, + distinct: ['id', 'versionID'], + }); + + // Once we have the child flows, we need to filter them + // using the flowObjectFilters + // This search needs to be also done by chunks + return childFlows.map((ref) => ({ + id: createBrandedValue(ref.id), + versionID: ref.versionID, + })); + } + + /** + * This method progressively search the flows + * accumulating the results in the flowResponse + * until the limit is reached or there are no more flows + * in the sortedFlows + * + * Since this is a recursive, the exit condition is when + * the flowResponse length is equal to the limit + * or the reducedFlows length is less than the limit after doing the search + * + * @param models + * @param sortedFlows + * @param limit + * @param offset + * @param orderBy + * @param flowResponse + * @returns list of flows + */ + async progresiveSearch( + models: Database, + referenceFlowList: UniqueFlowEntity[], + batchSize: number, + offset: number, + stopOnBatchSize: boolean, + flowResponse: FlowInstance[], + flowWhere?: FlowWhere, + orderBy?: FlowOrderByCond + ): Promise { + const reducedFlows = referenceFlowList.slice(offset, offset + batchSize); + + const conditions = buildSearchFlowsConditions(reducedFlows, flowWhere); + + const flows = await this.getFlows({ models, conditions, orderBy }); + + flowResponse.push(...flows); + + if ( + (stopOnBatchSize && flowResponse.length === batchSize) || + reducedFlows.length < batchSize + ) { + return flowResponse; + } + + // Recursive call + offset += batchSize; + return await this.progresiveSearch( + models, + referenceFlowList, + batchSize, + offset, + stopOnBatchSize, + flowResponse, + flowWhere, + orderBy + ); + } +} diff --git a/src/domain-services/flows/graphql/args.ts b/src/domain-services/flows/graphql/args.ts new file mode 100644 index 00000000..367e3e13 --- /dev/null +++ b/src/domain-services/flows/graphql/args.ts @@ -0,0 +1,158 @@ +import { ArgsType, Field, InputType, Int } from 'type-graphql'; +import { PaginationArgs } from '../../../utils/graphql/pagination'; +import { FlowObjectType } from '../../flow-object/model'; +import { type SystemID } from '../../report-details/graphql/types'; +import { type FlowSortField, type FlowStatusFilter } from './types'; + +@InputType() +export class SearchFlowsFilters { + @Field(() => [Int], { nullable: true }) + id: number[] | null; + + @Field(() => Boolean, { nullable: true }) + activeStatus: boolean | null; + + @Field(() => Int, { nullable: true }) + amountUSD: number | null; + + @Field(() => Boolean, { nullable: true }) + restricted: boolean | null; +} + +@InputType() +export class NestedFlowFilters { + @Field(() => String, { nullable: true }) + reporterRefCode: string | null; + + @Field(() => String, { nullable: true }) + sourceSystemID: string | null; + + @Field(() => Number, { nullable: true }) + legacyID: number | null; + + @Field(() => String, { nullable: true }) + systemID: SystemID | null; +} + +@InputType() +export class FlowCategoryFilters { + @Field(() => [FlowCategory], { nullable: true }) + categoryFilters: FlowCategory[]; +} + +@InputType() +export class FlowObjectFilters { + @Field(() => Number, { nullable: false }) + objectID: number; + + @Field({ nullable: false }) + direction: 'source' | 'destination'; + + @Field(() => String, { nullable: false }) + objectType: FlowObjectType; + + @Field({ nullable: true, defaultValue: false }) + inclusive: boolean; +} + +@InputType() +export class FlowCategory { + @Field(() => Number, { nullable: true }) + id: number; + + @Field({ nullable: true }) + group: string; + + @Field({ nullable: true }) + name: string; +} + +@ArgsType() +export class SearchFlowsArgs extends PaginationArgs { + @Field(() => SearchFlowsFilters, { nullable: true }) + flowFilters: SearchFlowsFilters; + + @Field(() => [FlowObjectFilters], { nullable: true }) + flowObjectFilters: FlowObjectFilters[]; + + @Field(() => NestedFlowFilters, { nullable: true }) + nestedFlowFilters: NestedFlowFilters; + + @Field({ nullable: true }) + includeChildrenOfParkedFlows: boolean; + + @Field(() => [FlowCategory], { nullable: true }) + flowCategoryFilters: FlowCategory[]; + + @Field(() => Boolean, { nullable: true }) + pending: boolean; + + @Field(() => Boolean, { nullable: true }) + commitment: boolean; + + @Field(() => Boolean, { nullable: true }) + paid: boolean; + + @Field(() => Boolean, { nullable: true }) + pledge: boolean; + + @Field(() => Boolean, { nullable: true }) + carryover: boolean; + + @Field(() => Boolean, { nullable: true }) + parked: boolean; + + @Field(() => Boolean, { nullable: true }) + pass_through: boolean; + + @Field(() => Boolean, { nullable: true }) + standard: boolean; + + @Field(() => String, { nullable: true }) + status: FlowStatusFilter | null; +} + +@ArgsType() +export class SearchFlowsArgsNonPaginated { + @Field(() => SearchFlowsFilters, { nullable: true }) + flowFilters: SearchFlowsFilters; + + @Field(() => [FlowObjectFilters], { nullable: true }) + flowObjectFilters: FlowObjectFilters[]; + + @Field(() => NestedFlowFilters, { nullable: true }) + nestedFlowFilters: NestedFlowFilters; + + @Field({ name: 'includeChildrenOfParkedFlows', nullable: true }) + shouldIncludeChildrenOfParkedFlows: boolean; + + @Field(() => [FlowCategory], { nullable: true }) + flowCategoryFilters: FlowCategory[]; + + @Field(() => Boolean, { nullable: true }) + pending: boolean; + + @Field(() => Boolean, { nullable: true }) + commitment: boolean; + + @Field(() => Boolean, { nullable: true }) + paid: boolean; + + @Field(() => Boolean, { nullable: true }) + pledge: boolean; + + @Field(() => Boolean, { nullable: true }) + carryover: boolean; + + @Field(() => Boolean, { nullable: true }) + parked: boolean; + + @Field(() => Boolean, { nullable: true }) + pass_through: boolean; + + @Field(() => Boolean, { nullable: true }) + standard: boolean; + + @Field(() => String, { nullable: true }) + status: FlowStatusFilter | null; +} diff --git a/src/domain-services/flows/graphql/resolver.ts b/src/domain-services/flows/graphql/resolver.ts new file mode 100644 index 00000000..a50dab95 --- /dev/null +++ b/src/domain-services/flows/graphql/resolver.ts @@ -0,0 +1,32 @@ +import { Args, Ctx, Query, Resolver } from 'type-graphql'; +import { Service } from 'typedi'; +import Context from '../../Context'; +import { FlowSearchService } from '../flow-search-service'; +import { SearchFlowsArgs } from './args'; +import { Flow, FlowSearchResult, FlowSearchResultNonPaginated } from './types'; + +@Service() +@Resolver(Flow) +export default class FlowResolver { + constructor(private flowSearchService: FlowSearchService) {} + + @Query(() => FlowSearchResult) + async searchFlows( + @Ctx() context: Context, + @Args(() => SearchFlowsArgs, { validate: false }) + args: SearchFlowsArgs + ): Promise { + return await this.flowSearchService.search(context.models, args); + } + + @Query(() => FlowSearchResultNonPaginated) + async searchFlowsBatches( + @Ctx() context: Context, + @Args(() => SearchFlowsArgs, { validate: false }) + args: SearchFlowsArgs + ): Promise { + // Set default batch size to 1000 + args.limit = args.limit > 0 ? args.limit : 1000; + return await this.flowSearchService.searchBatches(context.models, args); + } +} diff --git a/src/domain-services/flows/graphql/types.ts b/src/domain-services/flows/graphql/types.ts new file mode 100644 index 00000000..1280c9dd --- /dev/null +++ b/src/domain-services/flows/graphql/types.ts @@ -0,0 +1,200 @@ +import { Field, ObjectType } from 'type-graphql'; +import { PageInfo } from '../../../utils/graphql/pagination'; +import { BaseType } from '../../base-types'; +import { Category } from '../../categories/graphql/types'; +import { BaseLocationWithDirection } from '../../location/graphql/types'; +import { Organization } from '../../organizations/graphql/types'; +import { BasePlan } from '../../plans/graphql/types'; +import { ReportDetail } from '../../report-details/graphql/types'; +import { UsageYear } from '../../usage-years/graphql/types'; + +@ObjectType() +export class FlowExternalReference { + @Field({ nullable: false }) + systemID: string; + + @Field(() => Number, { nullable: false }) + flowID: number; + + @Field({ nullable: false }) + externalRecordID: string; + + @Field(() => Number, { nullable: false }) + versionID: number; + + @Field({ nullable: false }) + createdAt: string; + + @Field({ nullable: false }) + updatedAt: string; + + @Field({ nullable: false }) + externalRecordDate: string; +} + +@ObjectType() +export class FlowParkedParentSource { + @Field(() => [Number], { nullable: false }) + organization: number[]; + + @Field(() => [String], { nullable: false }) + orgName: string[]; + + @Field(() => [String], { nullable: false }) + abbreviation: string[]; +} + +@ObjectType() +export class BaseFlow extends BaseType { + @Field(() => Number, { nullable: false }) + id: number; + + @Field(() => Number, { nullable: false }) + versionID: number; + + @Field({ nullable: false }) + amountUSD: string; + + @Field({ nullable: false }) + activeStatus: boolean; + + @Field({ nullable: false }) + restricted: boolean; + + @Field(() => String, { nullable: true }) + flowDate: string | null; + + @Field(() => String, { nullable: true }) + decisionDate: string | null; + + @Field(() => String, { nullable: true }) + firstReportedDate: string | null; + + @Field(() => String, { nullable: true }) + budgetYear: string | null; + + @Field(() => String, { nullable: true }) + exchangeRate: string | null; + + @Field(() => String, { nullable: true }) + origAmount: string | null; + + @Field(() => String, { nullable: true }) + origCurrency: string | null; + + @Field(() => String, { nullable: true }) + description: string | null; + + @Field(() => String, { nullable: true }) + notes: string | null; + + @Field(() => String, { nullable: true }) + versionStartDate: string | null; + + @Field(() => String, { nullable: true }) + versionEndDate: string | null; + + @Field(() => Boolean, { nullable: true }) + newMoney: boolean | null; +} + +@ObjectType() +export class Flow extends BaseFlow { + @Field(() => [Category], { nullable: false }) + categories: Category[]; + + // Organizations + @Field(() => [Organization], { nullable: false }) + organizations: Organization[]; + + @Field(() => [Organization], { nullable: false }) + sourceOrganizations: Organization[]; + + @Field(() => [Organization], { nullable: false }) + destinationOrganizations: Organization[]; + + // Plans + @Field(() => [BasePlan], { nullable: false }) + plans: BasePlan[]; + + @Field(() => [BasePlan], { nullable: false }) + sourcePlans: BasePlan[]; + + @Field(() => [BasePlan], { nullable: false }) + destinationPlans: BasePlan[]; + + // Locations + @Field(() => [BaseLocationWithDirection], { nullable: false }) + locations: BaseLocationWithDirection[]; + + @Field(() => [BaseLocationWithDirection], { nullable: false }) + sourceLocations: BaseLocationWithDirection[]; + + @Field(() => [BaseLocationWithDirection], { nullable: false }) + destinationLocations: BaseLocationWithDirection[]; + + // UsageYears + @Field(() => [UsageYear], { nullable: false }) + usageYears: UsageYear[]; + + @Field(() => [UsageYear], { nullable: false }) + sourceUsageYears: UsageYear[]; + + @Field(() => [UsageYear], { nullable: false }) + destinationUsageYears: UsageYear[]; + + // Nested fields + @Field(() => [Number], { nullable: false }) + childIDs: number[]; + + @Field(() => [Number], { nullable: false }) + parentIDs: number[]; + + @Field(() => [FlowExternalReference], { nullable: false }) + externalReferences: FlowExternalReference[]; + + @Field(() => [ReportDetail], { nullable: false }) + reportDetails: ReportDetail[]; + + @Field(() => FlowParkedParentSource, { nullable: true }) + parkedParentSource: FlowParkedParentSource | null; +} + +@ObjectType() +export class FlowSearchResult extends PageInfo { + @Field(() => [Flow], { nullable: false }) + flows: Flow[]; +} + +@ObjectType() +export class FlowSearchResultNonPaginated { + @Field(() => [Flow], { nullable: false }) + flows: Flow[]; + + @Field(() => Number, { nullable: false }) + flowsCount: number; +} + +export type FlowSortField = + | 'flow.id' + | 'flow.versionID' + | 'flow.amountUSD' + | 'flow.updatedAt' + | 'flow.activeStatus' + | 'flow.restricted' + | 'flow.newMoney' + | 'flow.flowDate' + | 'flow.decisionDate' + | 'flow.firstReportedDate' + | 'flow.budgetYear' + | 'flow.origAmount' + | 'flow.origCurrency' + | 'flow.exchangeRate' + | 'flow.description' + | 'flow.notes' + | 'flow.versionStartDate' + | 'flow.versionEndDate' + | 'flow.createdAt' + | 'flow.deletedAt'; + +export type FlowStatusFilter = 'new' | 'updated' | undefined; diff --git a/src/domain-services/flows/model.ts b/src/domain-services/flows/model.ts new file mode 100644 index 00000000..1944ca10 --- /dev/null +++ b/src/domain-services/flows/model.ts @@ -0,0 +1,36 @@ +import { type Database } from '@unocha/hpc-api-core/src/db'; +import { type FlowId } from '@unocha/hpc-api-core/src/db/models/flow'; +import { type Condition } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { type OrderByCond } from '@unocha/hpc-api-core/src/db/util/raw-model'; +import type { + FieldsOfModel, + InstanceOfModel, +} from '@unocha/hpc-api-core/src/db/util/types'; +import { type SortOrder } from '../../utils/graphql/pagination'; +import { type EntityDirection } from '../base-types'; + +export type FlowModel = Database['flow']; +export type FlowInstance = InstanceOfModel; +export type FlowWhere = Condition; +export type FlowFieldsDefinition = FieldsOfModel; +export type FlowOrderByCond = OrderByCond; // Can this be simplified somehow? +export type UniqueFlowEntity = { + id: FlowId; + versionID: number | null; +}; + +export type FlowOrderByWithSubEntity = { + column: keyof FlowInstance | string; + order: SortOrder; + entity: string; + subEntity?: string; + direction?: EntityDirection; +}; + +export interface IGetFlowsArgs { + models: Database; + limit?: number; + offset?: number; + conditions?: FlowWhere; + orderBy?: FlowOrderByCond; +} diff --git a/src/domain-services/flows/strategy/flow-search-strategy.ts b/src/domain-services/flows/strategy/flow-search-strategy.ts new file mode 100644 index 00000000..3275cb87 --- /dev/null +++ b/src/domain-services/flows/strategy/flow-search-strategy.ts @@ -0,0 +1,33 @@ +import { type Database } from '@unocha/hpc-api-core/src/db'; +import { type ShortcutCategoryFilter } from '../../categories/model'; +import { + type FlowCategory, + type FlowObjectFilters, + type NestedFlowFilters, + type SearchFlowsFilters, +} from '../graphql/args'; +import { type FlowStatusFilter } from '../graphql/types'; +import type { FlowInstance, FlowOrderByWithSubEntity } from '../model'; + +export interface FlowSearchStrategyResponse { + flows: FlowInstance[]; + count: number; +} + +export interface FlowSearchArgs { + models: Database; + flowFilters: SearchFlowsFilters; + flowObjectFilters: FlowObjectFilters[]; + flowCategoryFilters: FlowCategory[]; + nestedFlowFilters: NestedFlowFilters; + shortcutFilters: ShortcutCategoryFilter[] | null; + statusFilter: FlowStatusFilter | null; + shouldIncludeChildrenOfParkedFlows?: boolean; + limit: number; + offset: number; + orderBy?: FlowOrderByWithSubEntity; +} + +export interface FlowSearchStrategy { + search(args: FlowSearchArgs): Promise; +} diff --git a/src/domain-services/flows/strategy/flowID-search-strategy.ts b/src/domain-services/flows/strategy/flowID-search-strategy.ts new file mode 100644 index 00000000..b36396f8 --- /dev/null +++ b/src/domain-services/flows/strategy/flowID-search-strategy.ts @@ -0,0 +1,21 @@ +import { type Database } from '@unocha/hpc-api-core/src/db'; +import { type ShortcutCategoryFilter } from '../../categories/model'; +import { type FlowObjectFilterGrouped } from '../../flow-object/model'; +import { type FlowCategory, type NestedFlowFilters } from '../graphql/args'; +import { type UniqueFlowEntity } from '../model'; + +export interface FlowIdSearchStrategyResponse { + flows: UniqueFlowEntity[]; +} + +export interface FlowIdSearchStrategyArgs { + models: Database; + flowObjectFilterGrouped?: FlowObjectFilterGrouped; + flowCategoryConditions?: FlowCategory[]; + nestedFlowFilters?: NestedFlowFilters; + shortcutFilters?: ShortcutCategoryFilter[] | null; +} + +export interface FlowIDSearchStrategy { + search(args: FlowIdSearchStrategyArgs): Promise; +} diff --git a/src/domain-services/flows/strategy/impl/get-flowIds-flow-category-conditions-strategy-impl.ts b/src/domain-services/flows/strategy/impl/get-flowIds-flow-category-conditions-strategy-impl.ts new file mode 100644 index 00000000..96d9d393 --- /dev/null +++ b/src/domain-services/flows/strategy/impl/get-flowIds-flow-category-conditions-strategy-impl.ts @@ -0,0 +1,100 @@ +import { type Database } from '@unocha/hpc-api-core/src/db'; +import { type CategoryId } from '@unocha/hpc-api-core/src/db/models/category'; +import { + Op, + type Condition, +} from '@unocha/hpc-api-core/src/db/util/conditions'; +import { type InstanceOfModel } from '@unocha/hpc-api-core/src/db/util/types'; +import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; +import { Service } from 'typedi'; +import { FlowService } from '../../flow-service'; +import type { UniqueFlowEntity } from '../../model'; +import { + type FlowIDSearchStrategy, + type FlowIdSearchStrategyArgs, + type FlowIdSearchStrategyResponse, +} from '../flowID-search-strategy'; +import { mapFlowCategoryConditionsToWhereClause } from './utils'; + +@Service() +export class GetFlowIdsFromCategoryConditionsStrategyImpl + implements FlowIDSearchStrategy +{ + constructor(private readonly flowService: FlowService) {} + + async search( + args: FlowIdSearchStrategyArgs + ): Promise { + const { models, flowCategoryConditions, shortcutFilters } = args; + + let categoriesIds: CategoryId[] = []; + + let whereClause = null; + if (flowCategoryConditions) { + whereClause = mapFlowCategoryConditionsToWhereClause( + flowCategoryConditions + ); + } + if (whereClause) { + const categories = await models.category.find({ + where: whereClause, + }); + + categoriesIds = categories.map((category) => category.id); + } + + // Add category IDs from shortcut filter + // to the list of category IDs IN or NOT_IN + const categoriesIdsFromShortcutFilterIN: CategoryId[] = []; + const categoriesIdsFromShortcutFilterNOTIN: CategoryId[] = []; + + if (shortcutFilters) { + for (const shortcut of shortcutFilters) { + if (shortcut.operation === Op.IN) { + categoriesIdsFromShortcutFilterIN.push( + createBrandedValue(shortcut.id) + ); + } else { + categoriesIdsFromShortcutFilterNOTIN.push( + createBrandedValue(shortcut.id) + ); + } + } + } + + // Search categoriesRef with categoriesID IN and categoriesIdsFromShortcutFilterIN + // and categoriesIdsFromShortcutFilterNOTIN + const where: Condition> = { + objectType: 'flow', + }; + + if (categoriesIdsFromShortcutFilterNOTIN.length > 0) { + where['categoryID'] = { + [Op.NOT_IN]: categoriesIdsFromShortcutFilterNOTIN, + }; + } + + const categoriesIDsIN = [ + ...categoriesIds, + ...categoriesIdsFromShortcutFilterIN, + ]; + + if (categoriesIDsIN.length > 0) { + where['categoryID'] = { [Op.IN]: categoriesIDsIN }; + } + + const categoriesRef = await models.categoryRef.find({ + where, + distinct: ['objectID', 'versionID'], + }); + + // Map categoryRef to UniqueFlowEntity (flowId and versionID) + const flowIDsFromCategoryRef: UniqueFlowEntity[] = categoriesRef.map( + (catRef) => ({ + id: createBrandedValue(catRef.objectID), + versionID: catRef.versionID, + }) + ); + return { flows: flowIDsFromCategoryRef }; + } +} diff --git a/src/domain-services/flows/strategy/impl/get-flowIds-flow-from-nested-flow-filters-strategy-impl.ts b/src/domain-services/flows/strategy/impl/get-flowIds-flow-from-nested-flow-filters-strategy-impl.ts new file mode 100644 index 00000000..109348df --- /dev/null +++ b/src/domain-services/flows/strategy/impl/get-flowIds-flow-from-nested-flow-filters-strategy-impl.ts @@ -0,0 +1,102 @@ +import { Service } from 'typedi'; +import { ExternalReferenceService } from '../../../external-reference/external-reference-service'; +import { LegacyService } from '../../../legacy/legacy-service'; +import { ReportDetailService } from '../../../report-details/report-detail-service'; +import { FlowService } from '../../flow-service'; +import type { UniqueFlowEntity } from '../../model'; +import { + type FlowIDSearchStrategy, + type FlowIdSearchStrategyArgs, + type FlowIdSearchStrategyResponse, +} from '../flowID-search-strategy'; +import { intersectUniqueFlowEntities } from './utils'; + +@Service() +export class GetFlowIdsFromNestedFlowFiltersStrategyImpl + implements FlowIDSearchStrategy +{ + constructor( + private readonly reportDetailService: ReportDetailService, + private readonly legacyService: LegacyService, + private readonly externalRefenceService: ExternalReferenceService, + private readonly flowService: FlowService + ) {} + + async search( + args: FlowIdSearchStrategyArgs + ): Promise { + const { models, nestedFlowFilters } = args; + + let flowsReporterReferenceCode: UniqueFlowEntity[] = []; + let flowsSourceSystemId: UniqueFlowEntity[] = []; + let flowsSystemId: UniqueFlowEntity[] = []; + const flowsLegacyId: UniqueFlowEntity[] = []; + + // Get the flowIDs using 'reporterReferenceCode' + if (nestedFlowFilters?.reporterRefCode) { + flowsReporterReferenceCode = + await this.reportDetailService.getUniqueFlowIDsFromReportDetailsByReporterReferenceCode( + models, + nestedFlowFilters.reporterRefCode + ); + } + + // Get the flowIDs using 'sourceSystemID' from 'reportDetail' + if (nestedFlowFilters?.sourceSystemID) { + flowsSourceSystemId = + await this.reportDetailService.getUniqueFlowIDsFromReportDetailsBySourceSystemID( + models, + nestedFlowFilters.sourceSystemID + ); + } + + // Get the flowIDs using 'systemID' from 'externalRefecence' + if (nestedFlowFilters?.systemID) { + flowsSystemId = + await this.externalRefenceService.getUniqueFlowIDsBySystemID( + models, + nestedFlowFilters.systemID + ); + } + + // Get the flowIDs using 'legacyID' + if (nestedFlowFilters?.legacyID) { + const flowID = await this.legacyService.getFlowIdFromLegacyId( + models, + nestedFlowFilters.legacyID + ); + + if (flowID) { + flowsLegacyId.push({ + id: flowID, + versionID: 1, + }); + } + } + + // Intersect the flowIDs from the nestedFlowFilters + const flowIDsFromNestedFlowFilters: UniqueFlowEntity[] = + intersectUniqueFlowEntities( + flowsReporterReferenceCode, + flowsSourceSystemId, + flowsSystemId, + flowsLegacyId + ); + + if (flowIDsFromNestedFlowFilters.length === 0) { + return { flows: [] }; + } + // Once gathered and disjoined the flowIDs from the nestedFlowFilters + // Look after this uniqueFlows in the flow table + const flows = await this.flowService.progresiveSearch( + models, + flowIDsFromNestedFlowFilters, + 1000, + 0, + false, // Stop when we have the limit + [] + ); + + return { flows }; + } +} diff --git a/src/domain-services/flows/strategy/impl/get-flowIds-flow-object-conditions-strategy-impl.ts b/src/domain-services/flows/strategy/impl/get-flowIds-flow-object-conditions-strategy-impl.ts new file mode 100644 index 00000000..0b354fc8 --- /dev/null +++ b/src/domain-services/flows/strategy/impl/get-flowIds-flow-object-conditions-strategy-impl.ts @@ -0,0 +1,57 @@ +import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { Service } from 'typedi'; +import { type UniqueFlowEntity } from '../../model'; +import { + type FlowIDSearchStrategy, + type FlowIdSearchStrategyArgs, + type FlowIdSearchStrategyResponse, +} from '../flowID-search-strategy'; +import { intersectUniqueFlowEntities } from './utils'; + +@Service() +export class GetFlowIdsFromObjectConditionsStrategyImpl + implements FlowIDSearchStrategy +{ + constructor() {} + + async search( + args: FlowIdSearchStrategyArgs + ): Promise { + const { flowObjectFilterGrouped, models } = args; + + if (!flowObjectFilterGrouped) { + return { flows: [] }; + } + + let intersectedFlows: UniqueFlowEntity[] = []; + + for (const [flowObjectType, group] of flowObjectFilterGrouped.entries()) { + for (const [direction, ids] of group.entries()) { + const condition = { + objectType: flowObjectType, + refDirection: direction, + objectID: { [Op.IN]: ids }, + }; + const flowObjectsFound = await models.flowObject.find({ + where: condition, + }); + + const uniqueFlowObjectsEntities: UniqueFlowEntity[] = + flowObjectsFound.map( + (flowObject) => + ({ + id: flowObject.flowID, + versionID: flowObject.versionID, + }) satisfies UniqueFlowEntity + ); + + intersectedFlows = intersectUniqueFlowEntities( + intersectedFlows, + uniqueFlowObjectsEntities + ); + } + } + + return { flows: intersectedFlows }; + } +} diff --git a/src/domain-services/flows/strategy/impl/only-flow-conditions-strategy-impl.ts b/src/domain-services/flows/strategy/impl/only-flow-conditions-strategy-impl.ts new file mode 100644 index 00000000..c46c3a44 --- /dev/null +++ b/src/domain-services/flows/strategy/impl/only-flow-conditions-strategy-impl.ts @@ -0,0 +1,65 @@ +import { Cond, Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { Service } from 'typedi'; +import { FlowService } from '../../flow-service'; +import { type FlowWhere } from '../../model'; +import { + type FlowSearchArgs, + type FlowSearchStrategy, + type FlowSearchStrategyResponse, +} from '../flow-search-strategy'; +import { + mapFlowOrderBy, + prepareFlowConditions, + prepareFlowStatusConditions, +} from './utils'; + +@Service() +export class OnlyFlowFiltersStrategy implements FlowSearchStrategy { + constructor(private readonly flowService: FlowService) {} + + async search(args: FlowSearchArgs): Promise { + const { models, flowFilters, orderBy, limit, offset, statusFilter } = args; + // Map flowConditions to where clause + let flowConditions: FlowWhere = prepareFlowConditions(flowFilters); + + // Add status filter conditions if provided + flowConditions = prepareFlowStatusConditions(flowConditions, statusFilter); + + // Build conditions object + // We need to add the condition to filter the deletedAt field + const whereClause: FlowWhere = { + [Cond.AND]: [ + { + deletedAt: { + [Op.IS_NULL]: true, + }, + }, + flowConditions ?? {}, + ], + }; + + const orderByFlow = mapFlowOrderBy(orderBy); + + const [flows, countRes] = await Promise.all([ + this.flowService.getFlows({ + models, + conditions: whereClause, + offset, + orderBy: orderByFlow, + limit, + }), + await models.flow.count({ + where: whereClause, + }), + ]); + + // Map count result query to count object + const countObject = countRes; + + // on certain conditions, this conversion from 'bigint' to 'number' can cause a loss of precision + // But in order to reach that point, the number of flows would have to be in the billions + // that is not a realistic scenario for this application + // Nonetheless, we can validate that using Number.MAX_SAFE_INTEGER as a threshold + return { flows, count: Number(countObject) }; + } +} diff --git a/src/domain-services/flows/strategy/impl/search-flow-by-filters-strategy-impl.ts b/src/domain-services/flows/strategy/impl/search-flow-by-filters-strategy-impl.ts new file mode 100644 index 00000000..3222fc91 --- /dev/null +++ b/src/domain-services/flows/strategy/impl/search-flow-by-filters-strategy-impl.ts @@ -0,0 +1,273 @@ +import { Service } from 'typedi'; +import { FlowService } from '../../flow-service'; +import type { FlowWhere, UniqueFlowEntity } from '../../model'; +import type { + FlowSearchArgs, + FlowSearchStrategy, + FlowSearchStrategyResponse, +} from '../flow-search-strategy'; +import { type FlowIdSearchStrategyResponse } from '../flowID-search-strategy'; +import { GetFlowIdsFromCategoryConditionsStrategyImpl } from './get-flowIds-flow-category-conditions-strategy-impl'; +import { GetFlowIdsFromNestedFlowFiltersStrategyImpl } from './get-flowIds-flow-from-nested-flow-filters-strategy-impl'; +import { GetFlowIdsFromObjectConditionsStrategyImpl } from './get-flowIds-flow-object-conditions-strategy-impl'; +import { + defaultFlowOrderBy, + defaultSearchFlowFilter, + intersectUniqueFlowEntities, + mapFlowFiltersToFlowObjectFiltersGrouped, + mapFlowOrderBy, + mergeUniqueEntities, + prepareFlowConditions, + prepareFlowStatusConditions, +} from './utils'; + +@Service() +export class SearchFlowByFiltersStrategy implements FlowSearchStrategy { + constructor( + private readonly flowService: FlowService, + private readonly getFlowIdsFromCategoryConditions: GetFlowIdsFromCategoryConditionsStrategyImpl, + private readonly getFlowIdsFromObjectConditions: GetFlowIdsFromObjectConditionsStrategyImpl, + private readonly getFlowIdsFromNestedFlowFilters: GetFlowIdsFromNestedFlowFiltersStrategyImpl + ) {} + + async search(args: FlowSearchArgs): Promise { + const { + models, + flowFilters, + flowObjectFilters, + flowCategoryFilters, + nestedFlowFilters, + limit, + offset, + shortcutFilters, + statusFilter, + orderBy, + shouldIncludeChildrenOfParkedFlows, + } = args; + + // First, we need to check if we need to sort by a certain entity + // and if so, we need to map the orderBy to be from that entity + // obtain the entities relation to the flow + // to be able to sort the flows using the entity + const isSortByEntity = orderBy && orderBy.entity !== 'flow'; + const sortByFlowIDs: UniqueFlowEntity[] = []; + const orderByForFlow = mapFlowOrderBy(orderBy); + + if (isSortByEntity) { + // Get the flowIDs using the orderBy entity + const flowIDsFromSortingEntity: UniqueFlowEntity[] = + await this.flowService.getFlowIDsFromEntity(models, orderBy); + // Since there can be many flowIDs returned + // This can cause 'Maximum call stack size exceeded' error + // When using the spread operator - a workaround is to use push fot each element + // also, we need to map the FlowEntity to UniqueFlowEntity + for (const uniqueFlow of flowIDsFromSortingEntity) { + sortByFlowIDs.push(uniqueFlow); + } + } else { + // In this case we fetch the list of flows from the database + // using the orderBy + const flowsToSort: UniqueFlowEntity[] = await this.flowService.getFlows({ + models, + orderBy: orderByForFlow, + }); + + // Since there can be many flowIDs returned + // This can cause 'Maximum call stack size exceeded' error + // When using the spread operator - a workaround is to use push fot each element + // also, we need to map the FlowEntity to UniqueFlowEntity + for (const flow of flowsToSort) { + sortByFlowIDs.push(flow); + } + } + + // We need to fetch the flowIDs by the nestedFlowFilters + // if there are any + const isFilterByNestedFilters = nestedFlowFilters !== undefined; + const flowIDsFromNestedFlowFilters: UniqueFlowEntity[] = []; + + if (isFilterByNestedFilters) { + const { flows }: FlowIdSearchStrategyResponse = + await this.getFlowIdsFromNestedFlowFilters.search({ + models, + nestedFlowFilters, + }); + + // If after this filter we have no flows, we can return an empty array + if (flows.length === 0) { + return { flows: [], count: 0 }; + } + // Since there can be many flowIDs returned + // This can cause 'Maximum call stack size exceeded' error + // When using the spread operator - a workaround is to use push fot each element + for (const flow of flows) { + flowIDsFromNestedFlowFilters.push(flow); + } + } + + // Now we need to check if we need to filter by category + // if it's using any of the shorcuts + // or if there are any flowCategoryFilters + const isSearchByCategoryShotcut = + shortcutFilters !== null && shortcutFilters.length > 0; + + const isFilterByCategory = + isSearchByCategoryShotcut || flowCategoryFilters?.length > 0; + + const flowsFromCategoryFilters: UniqueFlowEntity[] = []; + + if (isFilterByCategory) { + const { flows }: FlowIdSearchStrategyResponse = + await this.getFlowIdsFromCategoryConditions.search({ + models, + flowCategoryConditions: flowCategoryFilters ?? [], + shortcutFilters, + }); + + // If after this filter we have no flows, we can return an empty array + if (flows.length === 0) { + return { flows: [], count: 0 }; + } + + // Since there can be many flowIDs returned + // This can cause 'Maximum call stack size exceeded' error + // When using the spread operator - a workaround is to use push fot each element + for (const flow of flows) { + flowsFromCategoryFilters.push(flow); + } + } + + // After that, if we need to filter by flowObjects + // Obtain the flowIDs from the flowObjects + const isFilterByFlowObjects = flowObjectFilters?.length > 0; + + const flowsFromObjectFilters: UniqueFlowEntity[] = []; + if (isFilterByFlowObjects) { + // Firts step is to map the filters to the FlowObjectFiltersGrouped + // To allow doing inclusive filtering between filters of the same type+direction + // But exclusive filtering between filters of different type+direction + const flowObjectFiltersGrouped = + mapFlowFiltersToFlowObjectFiltersGrouped(flowObjectFilters); + + const { flows }: FlowIdSearchStrategyResponse = + await this.getFlowIdsFromObjectConditions.search({ + models, + flowObjectFilterGrouped: flowObjectFiltersGrouped, + }); + + // If after this filter we have no flows, we can return an empty array + if (flows.length === 0) { + return { flows: [], count: 0 }; + } + + // Since there can be many flowIDs returned + // This can cause 'Maximum call stack size exceeded' error + // When using the spread operator - a workaround is to use push fot each element + for (const flow of flows) { + flowsFromObjectFilters.push(flow); + } + + // If 'includeChildrenOfParkedFlows' is defined and true + // we need to obtain the flowIDs from the childs whose parent flows are parked + if (shouldIncludeChildrenOfParkedFlows) { + // We need to obtain the flowIDs from the childs whose parent flows are parked + const childs = + await this.flowService.getParkedParentFlowsByFlowObjectFilter( + models, + flowObjectFiltersGrouped + ); + + for (const child of childs) { + flowsFromObjectFilters.push(child); + } + } + } + + // Lastly, we need to check if we need to filter by flow + // And if we didn't did it before when sorting by entity + // if so, we need to obtain the flowIDs from the flowFilters + const isFilterByFlow = flowFilters !== undefined; + const isFilterByFlowStatus = statusFilter !== undefined; + + const flowsFromFlowFilters: UniqueFlowEntity[] = []; + if (isFilterByFlow || isFilterByFlowStatus) { + let flowConditions: FlowWhere = prepareFlowConditions(flowFilters); + // Add status filter conditions if provided + flowConditions = prepareFlowStatusConditions( + flowConditions, + statusFilter + ); + + const orderByForFlowFilter = isSortByEntity + ? defaultFlowOrderBy() + : orderByForFlow; + + const flows: UniqueFlowEntity[] = await this.flowService.getFlows({ + models, + conditions: flowConditions, + orderBy: orderByForFlowFilter, + }); + + // If after this filter we have no flows, we can return an empty array + if (flows.length === 0) { + return { flows: [], count: 0 }; + } + + // Since there can be many flowIDs returned + // This can cause 'Maximum call stack size exceeded' error + // When using the spread operator - a workaround is to use push fot each element + // also, we need to map the FlowEntity to UniqueFlowEntity + for (const flow of flows) { + flowsFromFlowFilters.push(flow); + } + } + + // We need to intersect the flowIDs from the flowObjects, flowCategoryFilters and flowFilters + // to obtain the flowIDs that match all the filters + const deduplicatedFlows: UniqueFlowEntity[] = intersectUniqueFlowEntities( + flowsFromCategoryFilters, + flowsFromObjectFilters, + flowsFromFlowFilters, + flowIDsFromNestedFlowFilters + ); + + if (deduplicatedFlows.length === 0) { + return { flows: [], count: 0 }; + } + + // We are going to sort the deduplicated flows + // using the sortByFlowIDs if there are any + let sortedFlows: UniqueFlowEntity[] = []; + // While sorting we have the same amount or less flows 'sorted' than deduplicatedFlows + // That means we need to keep the sortedFilters and then keep the rest of deduplicatedFlows thar are not in sortedFlows + // If we don't do this it may cause that just changing the orderBy we get different results + // Because we get rid of those flows that are not present in the sortedFlows list + sortedFlows = intersectUniqueFlowEntities(sortByFlowIDs, deduplicatedFlows); + + sortedFlows = mergeUniqueEntities(sortedFlows, deduplicatedFlows); + + const count = sortedFlows.length; + + const flows = await this.flowService.progresiveSearch( + models, + sortedFlows, + limit, + offset ?? 0, + true, // Stop when we have the limit + [], + defaultSearchFlowFilter, + orderByForFlow + ); + + if (isSortByEntity) { + // Sort the flows using the sortedFlows as referenceList + flows.sort((a, b) => { + const aIndex = sortedFlows.findIndex((flow) => flow.id === a.id); + const bIndex = sortedFlows.findIndex((flow) => flow.id === b.id); + return aIndex - bIndex; + }); + } + + return { flows, count }; + } +} diff --git a/src/domain-services/flows/strategy/impl/utils.ts b/src/domain-services/flows/strategy/impl/utils.ts new file mode 100644 index 00000000..07e54539 --- /dev/null +++ b/src/domain-services/flows/strategy/impl/utils.ts @@ -0,0 +1,416 @@ +import { type Database } from '@unocha/hpc-api-core/src/db'; +import { Cond, Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import type { InstanceDataOf } from '@unocha/hpc-api-core/src/db/util/model-definition'; +import { type InstanceOfModel } from '@unocha/hpc-api-core/src/db/util/types'; +import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; +import type * as t from 'io-ts'; +import { type OrderBy } from '../../../../utils/database-types'; +import { type SortOrder } from '../../../../utils/graphql/pagination'; +import { type EntityDirection } from '../../../base-types'; +import { type FlowObjectWhere } from '../../../flow-object/flow-object-service'; +import type { + FlowObjectFilterGrouped, + FlowObjectType, +} from '../../../flow-object/model'; +import type { + FlowCategory, + FlowObjectFilters, + SearchFlowsFilters, +} from '../../graphql/args'; +import type { FlowSortField, FlowStatusFilter } from '../../graphql/types'; +import type { + FlowFieldsDefinition, + FlowInstance, + FlowOrderByCond, + FlowOrderByWithSubEntity, + FlowWhere, + UniqueFlowEntity, +} from '../../model'; + +export const sortingColumnMapping: Map = new Map< + string, + string +>([ + ['reporterRefCode', 'refCode'], + ['sourceID', 'sourceID'], +]); + +export const defaultSearchFlowFilter: FlowWhere = { + deletedAt: null, +}; + +type FlowOrderByCommon = { + order: SortOrder; + direction: EntityDirection; + subEntity?: string; +}; + +export type FlowOrderBy = FlowOrderByCommon & + ( + | { + column: keyof FlowInstance; + entity: 'flow'; + } + | { + column: keyof InstanceOfModel; + entity: 'externalReference'; + } + | { + column: keyof InstanceOfModel; + entity: 'emergency'; + } + | { + entity: 'globalCluster'; + column: keyof InstanceOfModel; + } + | { + entity: 'governingEntity'; + column: keyof InstanceOfModel; + } + | { + entity: 'location'; + column: keyof InstanceOfModel; + } + | { + entity: 'organization'; + column: keyof InstanceOfModel; + } + | { + entity: 'plan'; + column: keyof InstanceOfModel; + } + | { + entity: 'usageYear'; + column: keyof InstanceOfModel; + } + | { + entity: 'planVersion'; + column: keyof InstanceOfModel; + } + | { + entity: 'project'; + column: keyof InstanceOfModel; + } + ); + +export type FlowOrderByCodec = t.Type; + +export const mapFlowCategoryConditionsToWhereClause = ( + flowCategoryConditions: FlowCategory[] +) => { + if (flowCategoryConditions.length > 0) { + let whereClause = {}; + // Map category filters + // getting Id when possible + // or name and group otherwise + const categoryIdFilters: number[] = []; + const categoryFilters = new Map(); + for (const categoryFilter of flowCategoryConditions) { + if (categoryFilter.id) { + categoryIdFilters.push(categoryFilter.id); + } else if (categoryFilter.group && categoryFilter.name) { + const group = categoryFilter.group; + const name = categoryFilter.name; + + const groupsNamesFilter = categoryFilters.get(group) ?? []; + + groupsNamesFilter.push(name); + categoryFilters.set(group, groupsNamesFilter); + } + } + + if (categoryIdFilters.length > 0) { + whereClause = { + ...whereClause, + id: { + [Op.IN]: categoryIdFilters, + }, + }; + } + + // For each entry of the group name + // add a condition to the where clause + // with the names associated to the group + // both in the same AND clause + for (const [group, names] of categoryFilters) { + whereClause = { + ...whereClause, + [Cond.AND]: [ + { + group: { + [Op.LIKE]: group, + }, + name: { + [Op.IN]: names, + }, + }, + ], + }; + } + return whereClause; + } + + return null; +}; + +export const mapFlowOrderBy = ( + orderBy?: FlowOrderBy | FlowOrderByWithSubEntity +): OrderBy => { + if (!orderBy || orderBy.entity !== 'flow') { + return defaultFlowOrderBy(); + } + + return { + column: orderBy.column as keyof InstanceDataOf, + order: orderBy.order, + }; +}; + +export const defaultFlowOrderBy = (): FlowOrderByCond => { + return { + column: 'updatedAt', + order: 'desc', + } satisfies FlowOrderByCond; +}; + +export const prepareFlowConditions = ( + flowFilters: SearchFlowsFilters +): FlowWhere => { + let flowConditions: FlowWhere = { ...defaultSearchFlowFilter }; + + if (flowFilters) { + for (const [key, value] of Object.entries(flowFilters)) { + if (value !== undefined) { + if (key === 'id') { + const brandedIDs = value.map((id: number) => createBrandedValue(id)); + flowConditions[key] = { [Op.IN]: brandedIDs }; + } else { + const typedKey = key as keyof FlowWhere; + flowConditions = { ...flowConditions, [typedKey]: value }; + } + } + } + } + + return flowConditions satisfies FlowWhere; +}; + +export const mergeUniqueEntities = ( + listA: UniqueFlowEntity[], + listB: UniqueFlowEntity[] +): UniqueFlowEntity[] => { + if (listA.length === 0) { + return listB; + } + + if (listB.length === 0) { + return listA; + } + + // Convert the lists into a set for efficient lookup + const entityMapListA = new Set(listA.map(mapUniqueFlowEntitisSetKeyToSetkey)); + + const entityMapListB = new Set(listB.map(mapUniqueFlowEntitisSetKeyToSetkey)); + + for (const key of entityMapListB) { + if (!entityMapListA.has(key)) { + entityMapListA.add(key); + } + } + + // Convert the keys back to UniqueFlowEntity objects + return mapUniqueFlowEntitisSetKeyToUniqueFlowEntity(entityMapListA); +}; + +export const intersectUniqueFlowEntities = ( + ...lists: UniqueFlowEntity[][] +): UniqueFlowEntity[] => { + // If any of the lists is empty, remove it + lists = lists.filter((list) => list.length > 0); + + if (lists.length === 0) { + return []; + } + + if (lists.length === 1) { + return lists[0]; + } + + // Convert the first list into a set for efficient lookup + const initialSet = new Set(lists[0].map(mapUniqueFlowEntitisSetKeyToSetkey)); + + // Intersect the remaining lists with the initial set + for (let i = 1; i < lists.length; i++) { + const currentSet = new Set( + lists[i].map(mapUniqueFlowEntitisSetKeyToSetkey) + ); + for (const key of initialSet) { + if (!currentSet.has(key)) { + initialSet.delete(key); + } + } + } + + // Convert the keys back to UniqueFlowEntity objects + return mapUniqueFlowEntitisSetKeyToUniqueFlowEntity(initialSet); +}; + +export const mapUniqueFlowEntitisSetKeyToSetkey = ( + entity: UniqueFlowEntity +): string => { + return `${entity.id}_${entity.versionID}`; +}; + +export const mapUniqueFlowEntitisSetKeyToUniqueFlowEntity = ( + set: Set +): UniqueFlowEntity[] => { + return [...set].map((key) => { + const [id, versionID] = key.split('_').map(Number); + return { id: createBrandedValue(id), versionID } satisfies UniqueFlowEntity; + }); +}; + +export const prepareFlowStatusConditions = ( + flowConditions: FlowWhere, + statusFilter: FlowStatusFilter | null +): FlowWhere => { + if (statusFilter) { + if (statusFilter === 'new') { + // Flows with version 1 are considered new + flowConditions = { ...flowConditions, versionID: 1 }; + } else if (statusFilter === 'updated') { + // Flows with version greater than 1 are considered updated + flowConditions = { ...flowConditions, versionID: { [Op.GT]: 1 } }; + } + } + return flowConditions; +}; + +export const buildSearchFlowsConditions = ( + uniqueFlowEntities: UniqueFlowEntity[], + flowFilters?: FlowWhere +): FlowWhere => { + const whereClauses = uniqueFlowEntities.map((flow) => ({ + [Cond.AND]: [ + { id: flow.id }, + flow.versionID ? { versionID: flow.versionID } : {}, + ], + })); + + if (flowFilters) { + return { + [Cond.AND]: [ + { deletedAt: null }, + { [Cond.OR]: whereClauses }, + flowFilters, + ], + } satisfies FlowWhere; + } + + return { + [Cond.OR]: whereClauses, + }; +}; + +export const buildSearchFlowsObjectConditions = ( + uniqueFlowEntities: UniqueFlowEntity[], + flowObjectsWhere?: FlowObjectWhere +): FlowObjectWhere => { + const whereClauses = uniqueFlowEntities.map((flow) => ({ + [Cond.AND]: [ + { flowID: flow.id }, + flow.versionID ? { versionID: flow.versionID } : {}, + ], + })); + + if (flowObjectsWhere && Object.entries(flowObjectsWhere).length > 0) { + return { + [Cond.AND]: [flowObjectsWhere, ...whereClauses], + } satisfies FlowObjectWhere; + } + + return { + [Cond.OR]: whereClauses, + }; +}; + +export const mapFlowFiltersToFlowObjectFiltersGrouped = ( + flowObjectFilters: FlowObjectFilters[] +): FlowObjectFilterGrouped => { + const flowObjectFilterGrouped = new Map< + FlowObjectType, + Map + >(); + + for (const flowObjectFilter of flowObjectFilters) { + const objectType = flowObjectFilter.objectType; + const flowDirection = flowObjectFilter.direction; + const objectId = flowObjectFilter.objectID; + + // Get the map of flow object IDs for the given object type + // Or create a new map if it doesn't exist + const directionWithIDsMap = + flowObjectFilterGrouped.get(objectType) ?? + new Map(); + + // Get the list of flow object IDs for the given direction + // Or create a new list if it doesn't exist + const flowObjectIDs = directionWithIDsMap.get(flowDirection) ?? []; + flowObjectIDs.push(objectId); + + // Update the map with the new list of flow object IDs for the given direction + directionWithIDsMap.set(flowDirection, flowObjectIDs); + + // Update the map with the new map of direction+ids for the given object type + flowObjectFilterGrouped.set(objectType, directionWithIDsMap); + } + + return flowObjectFilterGrouped; +}; + +export const buildOrderBy = ( + sortField?: FlowSortField | string, + sortOrder?: SortOrder +): FlowOrderByWithSubEntity => { + const orderBy: FlowOrderByWithSubEntity = { + column: sortField ?? 'updatedAt', + order: sortOrder ?? ('desc' as SortOrder), + direction: 'source' as EntityDirection, + entity: 'flow', + } satisfies FlowOrderByWithSubEntity; + + // Check if sortField is a nested property + if (orderBy.column.includes('.')) { + // OrderBy can came in the format: + // column: 'organizations.source.name' + // or in the format: + // column: 'flow.updatedAt' + // or in the format: + // column: 'planVersion.source.name' + // in this last case, we need to look after the capitalized letter + // that will indicate the entity + // and the whole word will be the subEntity + const struct = orderBy.column.split('.'); + + if (struct.length === 2) { + orderBy.column = struct[1]; + orderBy.entity = struct[0]; + } else if (struct.length === 3) { + orderBy.column = struct[2]; + orderBy.direction = struct[1] as EntityDirection; + + // We need to look after the '-' character + // [0] will indicate the entity + // and [1] will be the subEntity + const splitted = struct[0].split('-'); + const entity = splitted[0]; + orderBy.entity = entity; + + if (entity === struct[0]) { + orderBy.subEntity = struct[0]; + } + } + } + + return orderBy; +}; diff --git a/src/domain-services/legacy/legacy-service.ts b/src/domain-services/legacy/legacy-service.ts new file mode 100644 index 00000000..060cf7fa --- /dev/null +++ b/src/domain-services/legacy/legacy-service.ts @@ -0,0 +1,24 @@ +import { type Database } from '@unocha/hpc-api-core/src/db'; +import { type FlowId } from '@unocha/hpc-api-core/src/db/models/flow'; +import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; +import { Service } from 'typedi'; + +@Service() +export class LegacyService { + async getFlowIdFromLegacyId( + models: Database, + legacyId: number + ): Promise { + const legacyEntry = await models.legacy.findOne({ + where: { + legacyID: legacyId, + objectType: 'flow', + }, + }); + + if (legacyEntry) { + return createBrandedValue(legacyEntry.objectID); + } + return null; + } +} diff --git a/src/domain-services/location/graphql/types.ts b/src/domain-services/location/graphql/types.ts index c00b648a..aef28e1e 100644 --- a/src/domain-services/location/graphql/types.ts +++ b/src/domain-services/location/graphql/types.ts @@ -1,7 +1,7 @@ import { Brand } from '@unocha/hpc-api-core/src/util/types'; import { MaxLength } from 'class-validator'; -import { Field, ID, Int, ObjectType, registerEnumType } from 'type-graphql'; -import { BaseType } from '../../base-types'; +import { Field, ID, ObjectType, registerEnumType } from 'type-graphql'; +import { BaseType, BaseTypeWithDirection } from '../../base-types'; export enum LocationStatus { active = 'active', @@ -25,7 +25,7 @@ export default class Location extends BaseType { @MaxLength(255) name?: string; - @Field(() => Int) + @Field(() => Number) adminLevel: number; // Accidentally optional @Field({ nullable: true }) @@ -34,7 +34,7 @@ export default class Location extends BaseType { @Field({ nullable: true }) longitude?: number; - @Field(() => Int, { nullable: true }) + @Field(() => Number, { nullable: true }) parentId?: number; @Field({ nullable: true }) @@ -47,9 +47,48 @@ export default class Location extends BaseType { @Field(() => LocationStatus) status?: LocationStatus; // Accidentally optional - @Field(() => Int, { nullable: true }) + @Field(() => Number, { nullable: true }) validOn?: number; @Field({ defaultValue: true }) itosSync: boolean; // Accidentally optional } + +@ObjectType() +export class BaseLocationWithDirection extends BaseTypeWithDirection { + @Field(() => Number, { nullable: true }) + id: number; + + @Field(() => String, { nullable: true }) + name: string | null; + + @Field({ nullable: true }) + externalId?: string; + + @Field(() => Number) + adminLevel: number | null; // Accidentally optional + + @Field(() => Number, { nullable: true }) + latitude: number | null; + + @Field(() => Number, { nullable: true }) + longitude: number | null; + + @Field(() => Number, { nullable: true }) + parentId: number | null; + + @Field(() => String, { nullable: true }) + iso3: string | null; + + @Field(() => String, { nullable: true }) + pcode: string | null; + + @Field(() => String) + status: string | null; // Accidentally optional + + @Field(() => Number, { nullable: true }) + validOn: string | number | null; + + @Field({ defaultValue: true }) + itosSync: boolean; // Accidentally optional +} diff --git a/src/domain-services/location/location-service.ts b/src/domain-services/location/location-service.ts index 67d78892..bbfdc70a 100644 --- a/src/domain-services/location/location-service.ts +++ b/src/domain-services/location/location-service.ts @@ -1,7 +1,11 @@ +import { type LocationId } from '@unocha/hpc-api-core/src/db/models/location'; import { type Database } from '@unocha/hpc-api-core/src/db/type'; +import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; import { type InstanceDataOfModel } from '@unocha/hpc-api-core/src/db/util/raw-model'; +import { getOrCreate } from '@unocha/hpc-api-core/src/util'; import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; import { Service } from 'typedi'; +import { type BaseLocationWithDirection } from './graphql/types'; @Service() export class LocationService { @@ -26,4 +30,71 @@ export class LocationService { where: { name: { [models.Op.ILIKE]: `%${name}%` } }, }); } + + async getLocationsForFlows( + locationsFO: Array>, + models: Database + ): Promise> { + const locationObjectsIDs: LocationId[] = locationsFO.map((locFO) => + createBrandedValue(locFO.objectID) + ); + + const locations: Array> = + await models.location.find({ + where: { + id: { + [Op.IN]: locationObjectsIDs, + }, + }, + }); + + const locationsMap = new Map(); + + for (const locFO of locationsFO) { + const flowId = locFO.flowID; + if (!locationsMap.has(flowId)) { + locationsMap.set(flowId, []); + } + const location = locations.find((loc) => loc.id === locFO.objectID); + + if (location) { + const locationsPerFlow = getOrCreate(locationsMap, flowId, () => []); + if ( + !locationsPerFlow.some( + (loc) => + loc.id === location.id && loc.direction === locFO.refDirection + ) + ) { + const locationMapped = this.mapLocationsToFlowLocations( + location, + locFO + ); + locationsPerFlow.push(locationMapped); + } + } + } + return locationsMap; + } + + private mapLocationsToFlowLocations( + location: InstanceDataOfModel, + locationFO: InstanceDataOfModel + ): BaseLocationWithDirection { + return { + id: location.id, + name: location.name, + direction: locationFO.refDirection, + createdAt: location.createdAt.toISOString(), + updatedAt: location.updatedAt.toISOString(), + adminLevel: location.adminLevel, + latitude: location.latitude, + longitude: location.longitude, + parentId: location.parentId, + iso3: location.iso3, + status: location.status, + validOn: location.validOn, + itosSync: location.itosSync, + pcode: location.pcode, + }; + } } diff --git a/src/domain-services/organizations/graphql/types.ts b/src/domain-services/organizations/graphql/types.ts new file mode 100644 index 00000000..b146751b --- /dev/null +++ b/src/domain-services/organizations/graphql/types.ts @@ -0,0 +1,41 @@ +import { Field, ObjectType } from 'type-graphql'; +import { BaseTypeWithDirection } from '../../base-types'; + +@ObjectType() +export class Organization extends BaseTypeWithDirection { + @Field(() => Number, { nullable: false }) + id: number; + + @Field({ nullable: true }) + name: string; + + @Field(() => String, { nullable: true }) + abbreviation: string | null; + + @Field(() => String, { nullable: true }) + url: string | null; + + @Field(() => Number, { nullable: true }) + parentID: number | null; + + @Field(() => String, { nullable: true }) + nativeName: string | null; + + @Field(() => String, { nullable: true }) + comments: string | null; + + @Field({ nullable: true }) + collectiveInd: boolean; + + @Field({ nullable: true }) + active: boolean; + + @Field(() => Number, { nullable: true }) + newOrganizationId: number | null; + + @Field({ nullable: true }) + verified: boolean; + + @Field(() => String, { nullable: true }) + notes: string | null; +} diff --git a/src/domain-services/organizations/organization-service.ts b/src/domain-services/organizations/organization-service.ts new file mode 100644 index 00000000..c35bd873 --- /dev/null +++ b/src/domain-services/organizations/organization-service.ts @@ -0,0 +1,97 @@ +import { type Database } from '@unocha/hpc-api-core/src/db'; +import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { type InstanceOfModel } from '@unocha/hpc-api-core/src/db/util/types'; +import { getOrCreate } from '@unocha/hpc-api-core/src/util'; +import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; +import { Service } from 'typedi'; +import { type EntityDirection } from '../base-types'; +import { type FlowObject } from '../flow-object/model'; +import { type Organization } from './graphql/types'; + +// Local type definitions to increase readability +type OrganizationModel = Database['organization']; +type OrganizationInstance = InstanceOfModel; +@Service() +export class OrganizationService { + async getOrganizationsForFlows( + organizationsFO: FlowObject[], + models: Database + ) { + const organizations: OrganizationInstance[] = + await models.organization.find({ + where: { + id: { + [Op.IN]: organizationsFO.map((orgFO) => + createBrandedValue(orgFO.objectID) + ), + }, + }, + }); + + const organizationsMap = new Map(); + + for (const orgFO of organizationsFO) { + const flowId = orgFO.flowID; + + if (!organizationsMap.has(flowId)) { + organizationsMap.set(flowId, []); + } + const organization = organizations.find( + (org) => org.id === orgFO.objectID + ); + + if (organization) { + const organizationPerFlow = getOrCreate( + organizationsMap, + flowId, + () => [] + ); + if ( + !organizationPerFlow.some( + (org) => + org.id === organization.id.valueOf() && + org.direction === orgFO.refDirection + ) + ) { + const organizationMapped: Organization = + this.mapOrganizationsToOrganizationFlows( + organization, + orgFO.refDirection + ); + const flowOrganizations = getOrCreate( + organizationsMap, + flowId, + () => [] + ); + flowOrganizations.push(organizationMapped); + organizationsMap.set(flowId, flowOrganizations); + } + } + } + + return organizationsMap; + } + + private mapOrganizationsToOrganizationFlows( + organization: OrganizationInstance, + refDirection: EntityDirection + ): Organization { + return { + id: organization.id, + direction: refDirection, + name: organization.name, + createdAt: organization.createdAt.toISOString(), + updatedAt: organization.updatedAt.toISOString(), + abbreviation: organization.abbreviation, + url: organization.url, + parentID: organization.parentID?.valueOf() ?? null, + nativeName: organization.nativeName, + comments: organization.comments, + collectiveInd: organization.collectiveInd, + active: organization.active, + newOrganizationId: organization.newOrganizationId?.valueOf() ?? null, + verified: organization.verified, + notes: organization.notes, + }; + } +} diff --git a/src/domain-services/plans/graphql/types.ts b/src/domain-services/plans/graphql/types.ts index c947291d..31dd8382 100644 --- a/src/domain-services/plans/graphql/types.ts +++ b/src/domain-services/plans/graphql/types.ts @@ -1,6 +1,7 @@ import { Brand } from '@unocha/hpc-api-core/src/util/types'; import { MaxLength } from 'class-validator'; -import { Field, ID, Int, ObjectType } from 'type-graphql'; +import { Field, ID, ObjectType } from 'type-graphql'; +import { BaseTypeWithDirection } from '../../base-types'; import PlanTag from '../../plan-tag/graphql/types'; @ObjectType() @@ -77,7 +78,7 @@ export default class Plan { @MaxLength(255) name: string; - @Field(() => [Int]) + @Field(() => [Number]) years: number[]; @Field(() => PlanFunding) @@ -95,3 +96,48 @@ export default class Plan { @Field(() => [PlanTag]) tags: PlanTag[]; } + +@ObjectType() +export class BasePlan extends BaseTypeWithDirection { + @Field(() => Number, { nullable: true }) + id: number; + + @Field({ nullable: true }) + name: string; + + @Field({ nullable: true }) + startDate: string; + + @Field({ nullable: true }) + endDate: string; + + @Field(() => String, { nullable: true }) + comments: string | null; + + @Field({ nullable: true }) + isForHPCProjects: boolean; + + @Field(() => String, { nullable: true }) + code: string | null; + + @Field(() => String, { nullable: true }) + customLocationCode: string | null; + + @Field(() => Number, { nullable: true }) + currentReportingPeriodId: number | null; + + @Field({ nullable: true }) + currentVersion: boolean; + + @Field({ nullable: true }) + latestVersion: boolean; + + @Field({ nullable: true }) + latestTaggedVersion: boolean; + + @Field(() => Number, { nullable: true }) + lastPublishedReportingPeriodId: number | null; + + @Field(() => String, { nullable: true }) + clusterSelectionType: string | null; +} diff --git a/src/domain-services/plans/plan-service.ts b/src/domain-services/plans/plan-service.ts index f7321b41..08ce0e23 100644 --- a/src/domain-services/plans/plan-service.ts +++ b/src/domain-services/plans/plan-service.ts @@ -1,8 +1,13 @@ import { type PlanId } from '@unocha/hpc-api-core/src/db/models/plan'; import { type Database } from '@unocha/hpc-api-core/src/db/type'; +import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { type InstanceDataOfModel } from '@unocha/hpc-api-core/src/db/util/raw-model'; +import { getOrCreate } from '@unocha/hpc-api-core/src/util'; import { NotFoundError } from '@unocha/hpc-api-core/src/util/error'; import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; import { Service } from 'typedi'; +import { type EntityDirection } from '../base-types'; +import { type BasePlan } from './graphql/types'; @Service() export class PlanService { @@ -44,4 +49,84 @@ export class PlanService { return years.map((y) => y.year); } + + async getPlansForFlows( + plansFO: Array>, + models: Database + ): Promise> { + const planObjectsIDs: PlanId[] = plansFO.map((planFO) => + createBrandedValue(planFO.objectID) + ); + const plans: Array> = + await models.plan.find({ + where: { + id: { + [Op.IN]: planObjectsIDs, + }, + }, + }); + + const plansMap = new Map(); + + for (const plan of plans) { + const planVersion = await models.planVersion.find({ + where: { + planId: plan.id, + currentVersion: true, + }, + }); + + for (const planFO of plansFO) { + if (planFO.objectID === plan.id) { + const flowId = planFO.flowID; + if (!plansMap.has(flowId)) { + plansMap.set(flowId, []); + } + const plansPerFlow = getOrCreate(plansMap, flowId, () => []); + if ( + !plansPerFlow.some( + (plan) => + plan.id === plan.id && plan.direction === planFO.refDirection + ) + ) { + const planMapped = this.mapPlansToFlowPlans( + plan, + planVersion[0], + planFO.refDirection + ); + plansPerFlow.push(planMapped); + } + } + } + } + + return plansMap; + } + + private mapPlansToFlowPlans( + plan: InstanceDataOfModel, + planVersion: InstanceDataOfModel, + direction: EntityDirection + ): BasePlan { + return { + id: plan.id.valueOf(), + name: planVersion.name, + createdAt: plan.createdAt.toISOString(), + updatedAt: plan.updatedAt.toISOString(), + direction: direction, + startDate: planVersion.startDate.toISOString(), + endDate: planVersion.endDate.toISOString(), + comments: planVersion.comments, + isForHPCProjects: planVersion.isForHPCProjects, + code: planVersion.code, + customLocationCode: planVersion.customLocationCode, + currentReportingPeriodId: planVersion.currentReportingPeriodId, + currentVersion: planVersion.currentVersion, + latestVersion: planVersion.latestVersion, + latestTaggedVersion: planVersion.latestTaggedVersion, + lastPublishedReportingPeriodId: + planVersion.lastPublishedReportingPeriodId, + clusterSelectionType: planVersion.clusterSelectionType, + }; + } } diff --git a/src/domain-services/report-details/graphql/types.ts b/src/domain-services/report-details/graphql/types.ts new file mode 100644 index 00000000..5a3135bb --- /dev/null +++ b/src/domain-services/report-details/graphql/types.ts @@ -0,0 +1,41 @@ +import { type EXTERNAL_DATA_SYSTEM_ID } from '@unocha/hpc-api-core/src/db/models/externalData'; +import type * as t from 'io-ts'; +import { Field, ObjectType } from 'type-graphql'; +import { BaseType } from '../../base-types'; +@ObjectType() +export class ReportDetail extends BaseType { + @Field(() => Number, { nullable: false }) + id: number; + + @Field({ nullable: false }) + flowID: number; + + @Field(() => Number, { nullable: false }) + versionID: number; + + @Field(() => String, { nullable: true }) + contactInfo: string | null; + + @Field({ nullable: false }) + source: string; + + @Field(() => String, { nullable: true }) + date: string | null; + + @Field(() => String, { nullable: true }) + sourceID: string | null; + + @Field(() => String, { nullable: true }) + refCode: string | null; + + @Field({ nullable: false }) + verified: boolean; + + @Field(() => Number, { nullable: true }) + organizationID: number | null; + + @Field(() => String, { nullable: true }) + channel: string | null; +} + +export type SystemID = t.TypeOf; diff --git a/src/domain-services/report-details/report-detail-service.ts b/src/domain-services/report-details/report-detail-service.ts new file mode 100644 index 00000000..a0e1cae5 --- /dev/null +++ b/src/domain-services/report-details/report-detail-service.ts @@ -0,0 +1,128 @@ +import { type Database } from '@unocha/hpc-api-core/src/db'; +import { type FlowId } from '@unocha/hpc-api-core/src/db/models/flow'; +import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { type InstanceDataOfModel } from '@unocha/hpc-api-core/src/db/util/raw-model'; +import { type InstanceOfModel } from '@unocha/hpc-api-core/src/db/util/types'; +import { getOrCreate } from '@unocha/hpc-api-core/src/util'; +import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; +import { Service } from 'typedi'; +import { type UniqueFlowEntity } from '../flows/model'; +import { type ReportDetail } from './graphql/types'; +@Service() +export class ReportDetailService { + async getReportDetailsForFlows( + flowIds: FlowId[], + models: Database + ): Promise> { + const reportDetails: Array> = + await models.reportDetail.find({ + where: { + flowID: { + [Op.IN]: flowIds, + }, + }, + skipValidation: true, + }); + + const reportDetailsMap = new Map(); + + for (const flowId of flowIds) { + if (!reportDetailsMap.has(flowId)) { + reportDetailsMap.set(flowId, []); + } + + const flowsReportingDetails = reportDetails.filter( + (report) => report.flowID === flowId + ); + + if (flowsReportingDetails && flowsReportingDetails.length > 0) { + const reportDetailsPerFlow = getOrCreate( + reportDetailsMap, + flowId, + () => [] + ); + + for (const reportDetail of flowsReportingDetails) { + const reportDetailMapped = + this.mapReportDetailsToFlowReportDetail(reportDetail); + reportDetailsPerFlow.push(reportDetailMapped); + } + } + } + + return reportDetailsMap; + } + + private mapReportDetailsToFlowReportDetail( + reportDetail: InstanceOfModel + ): ReportDetail { + return { + id: reportDetail.id, + flowID: reportDetail.flowID, + versionID: reportDetail.versionID, + contactInfo: reportDetail.contactInfo, + source: reportDetail.source, + date: reportDetail.date + ? new Date(reportDetail.date).toISOString() + : null, + sourceID: reportDetail.sourceID, + refCode: reportDetail.refCode, + verified: reportDetail.verified, + createdAt: reportDetail.createdAt.toISOString(), + updatedAt: reportDetail.updatedAt.toISOString(), + organizationID: reportDetail.organizationID, + channel: null, + }; + } + + async getUniqueFlowIDsFromReportDetailsByReporterReferenceCode( + models: Database, + reporterRefCode: string + ): Promise { + const reportDetails: Array> = + await models.reportDetail.find({ + where: { + refCode: reporterRefCode, + }, + skipValidation: true, + }); + + const flowIDs: UniqueFlowEntity[] = []; + + for (const reportDetail of reportDetails) { + flowIDs.push(this.mapReportDetailToUniqueFlowEntity(reportDetail)); + } + + return flowIDs; + } + + async getUniqueFlowIDsFromReportDetailsBySourceSystemID( + models: Database, + sourceSystemID: string + ): Promise { + const reportDetails: Array> = + await models.reportDetail.find({ + where: { + sourceID: sourceSystemID, + }, + skipValidation: true, + }); + + const flowIDs: UniqueFlowEntity[] = []; + + for (const report of reportDetails) { + flowIDs.push(this.mapReportDetailToUniqueFlowEntity(report)); + } + + return flowIDs; + } + + private mapReportDetailToUniqueFlowEntity( + reportDetail: InstanceDataOfModel + ): UniqueFlowEntity { + return { + id: createBrandedValue(reportDetail.flowID), + versionID: reportDetail.versionID, + }; + } +} diff --git a/src/domain-services/usage-years/graphql/types.ts b/src/domain-services/usage-years/graphql/types.ts new file mode 100644 index 00000000..b17d2cf6 --- /dev/null +++ b/src/domain-services/usage-years/graphql/types.ts @@ -0,0 +1,8 @@ +import { Field, ObjectType } from 'type-graphql'; +import { BaseTypeWithDirection } from '../../base-types'; + +@ObjectType() +export class UsageYear extends BaseTypeWithDirection { + @Field({ nullable: false }) + year: string; +} diff --git a/src/domain-services/usage-years/usage-year-service.ts b/src/domain-services/usage-years/usage-year-service.ts new file mode 100644 index 00000000..3b0eb9f9 --- /dev/null +++ b/src/domain-services/usage-years/usage-year-service.ts @@ -0,0 +1,72 @@ +import { type Database } from '@unocha/hpc-api-core/src/db'; +import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { type InstanceDataOfModel } from '@unocha/hpc-api-core/src/db/util/raw-model'; +import { getOrCreate } from '@unocha/hpc-api-core/src/util'; +import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; +import { Service } from 'typedi'; +import { type EntityDirection } from '../base-types'; +import { type FlowObject } from '../flow-object/model'; +import { type UsageYear } from './graphql/types'; + +type UsageYearModel = Database['usageYear']; +type UsageYearInstance = InstanceDataOfModel; +@Service() +export class UsageYearService { + async getUsageYearsForFlows( + usageYearsFO: FlowObject[], + models: Database + ): Promise> { + const usageYears: UsageYearInstance[] = await models.usageYear.find({ + where: { + id: { + [Op.IN]: usageYearsFO.map((usageYearFO) => + createBrandedValue(usageYearFO.objectID) + ), + }, + }, + }); + + const usageYearsMap = new Map(); + + for (const usageYearFO of usageYearsFO) { + const flowId = usageYearFO.flowID; + if (!usageYearsMap.has(flowId)) { + usageYearsMap.set(flowId, []); + } + const usageYear = usageYears.find( + (uYear) => uYear.id === usageYearFO.objectID + ); + + if (usageYear) { + const usageYearsPerFlow = getOrCreate(usageYearsMap, flowId, () => []); + if ( + !usageYearsPerFlow.some( + (uYear) => + uYear.year === usageYear.year && + uYear.direction === usageYearFO.refDirection + ) + ) { + const usageYearMapped = this.mapUsageYearsToFlowUsageYears( + usageYear, + usageYearFO.refDirection + ); + usageYearsPerFlow.push(usageYearMapped); + } + } + } + + return usageYearsMap; + } + + private mapUsageYearsToFlowUsageYears( + usageYear: UsageYearInstance, + refDirection: EntityDirection + ) { + return { + year: usageYear.year, + direction: refDirection, + createdAt: usageYear.createdAt.toISOString(), + updatedAt: usageYear.updatedAt.toISOString(), + }; + } +} diff --git a/src/utils/database-types.ts b/src/utils/database-types.ts new file mode 100644 index 00000000..3b2fd40f --- /dev/null +++ b/src/utils/database-types.ts @@ -0,0 +1,16 @@ +import type { + FieldDefinition, + InstanceDataOf, +} from '@unocha/hpc-api-core/src/db/util/model-definition'; + +export type OrderByCond = { + column: keyof T; + order?: 'asc' | 'desc'; +} & { + raw?: string; +}; + +export type OrderBy = { + column: keyof InstanceDataOf; + order?: 'asc' | 'desc'; +}; diff --git a/src/utils/graphql/pagination.ts b/src/utils/graphql/pagination.ts new file mode 100644 index 00000000..121ac6ab --- /dev/null +++ b/src/utils/graphql/pagination.ts @@ -0,0 +1,52 @@ +import { ArgsType, Field, ObjectType } from 'type-graphql'; + +export type SortOrder = 'asc' | 'desc'; + +export interface IItemPaged { + cursor: string; +} + +@ObjectType() +export class PageInfo { + @Field({ nullable: false }) + hasNextPage: boolean; + + @Field({ nullable: false }) + hasPreviousPage: boolean; + + @Field(() => Number, { nullable: false }) + prevPageCursor: number; + + @Field(() => Number, { nullable: false }) + nextPageCursor: number; + + @Field({ nullable: false }) + pageSize: number; + + @Field(() => String, { nullable: false }) + sortField: TSortFields; + + @Field({ nullable: false }) + sortOrder: string; + + @Field(() => Number, { nullable: false }) + total: number; +} + +@ArgsType() +export class PaginationArgs { + @Field({ nullable: false }) + limit: number; + + @Field(() => Number, { nullable: true }) + nextPageCursor: number; + + @Field(() => Number, { nullable: true }) + prevPageCursor: number; + + @Field(() => String, { nullable: true }) + sortField: TSortFields; + + @Field(() => String, { nullable: true, defaultValue: 'desc' }) + sortOrder: SortOrder; +} diff --git a/tests/resolvers/flows.spec.ts b/tests/resolvers/flows.spec.ts new file mode 100644 index 00000000..39fc64e7 --- /dev/null +++ b/tests/resolvers/flows.spec.ts @@ -0,0 +1,450 @@ +import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; +import { type GraphQLResponse } from 'apollo-server-types'; +import type { + Flow, + FlowSearchResult, +} from '../../src/domain-services/flows/graphql/types'; +import ContextProvider from '../testContext'; +const defaultPageSize = 10; + +const defaultSortField = '"flow.updatedAt"'; +const defaultSortOrder = '"DESC"'; + +type SearchFlowGQLResponse = { + searchFlows: FlowSearchResult; +}; + +function buildSimpleQuery( + limit: number | null, + sortField: string, + sortOrder: string, + pending: boolean = false +) { + const query = `query { + searchFlows( + limit: ${limit} + sortField: ${sortField} + sortOrder: ${sortOrder} + pending: ${pending} + flowFilters: { activeStatus: ${!pending} } + ) { + total + flows { + id + versionID + updatedAt + amountUSD + activeStatus + } + + prevPageCursor + + hasNextPage + + nextPageCursor + + hasPreviousPage + + pageSize + } + } + `; + + return query; +} + +function buildFullQuery( + limit: number, + sortField: string, + sortOrder: string, + pending: boolean = false +) { + const fullQuery = `query { + searchFlows( + limit: ${limit} + sortField: ${sortField} + sortOrder: ${sortOrder} + pending: ${pending} + flowFilters: { activeStatus: ${!pending} } + ) { + total + flows { + id + updatedAt + amountUSD + versionID + activeStatus + restricted + exchangeRate + flowDate + newMoney + decisionDate + categories { + id + name + group + createdAt + updatedAt + description + parentID + code + includeTotals + categoryRef { + objectID + versionID + objectType + categoryID + updatedAt + } + } + + organizations { + id + name + direction + abbreviation + } + + destinationOrganizations { + id + name + direction + abbreviation + } + + sourceOrganizations { + id + name + direction + abbreviation + } + + plans { + id + name + direction + } + + usageYears { + year + direction + } + childIDs + parentIDs + origAmount + origCurrency + locations { + id + name + direction + } + externalReferences { + systemID + flowID + externalRecordID + externalRecordDate + versionID + createdAt + updatedAt + } + reportDetails { + id + flowID + versionID + contactInfo + refCode + organizationID + channel + source + date + verified + updatedAt + createdAt + sourceID + } + parkedParentSource { + orgName + organization + } + } + + prevPageCursor + + hasNextPage + + nextPageCursor + + hasPreviousPage + + pageSize + } + } + `; + + return fullQuery; +} + +describe('Query should return Flow search', () => { + beforeAll(async () => { + const models = ContextProvider.Instance.models; + + const activeFlowsProt = []; + const pendingFlowsProt = []; + + // Create 20 active and pending flows + for (let i = 0; i < 20; i++) { + const flow = { + amountUSD: 10_000, + updatedAt: new Date(), + flowDate: new Date(), + origCurrency: 'USD', + origAmount: 10_000, + }; + + activeFlowsProt.push({ + ...flow, + activeStatus: true, + }); + + pendingFlowsProt.push({ + ...flow, + activeStatus: false, + }); + } + const activeFlows = await models.flow.createMany(activeFlowsProt); + const pendingFlows = await models.flow.createMany(pendingFlowsProt); + + // Create category group + const categoryGroup = { + name: 'Flow Status', + type: 'flowStatus' as const, + }; + + await models.categoryGroup.create(categoryGroup); + + // Create categories + const categoriesProt = [ + { + id: createBrandedValue(136), + name: 'Not Pending', + group: 'flowStatus' as const, + code: 'not-pending', + }, + { + id: createBrandedValue(45), + name: 'Pending', + group: 'flowStatus' as const, + code: 'pending', + }, + ]; + + await models.category.createMany(categoriesProt); + + // Asign categories to flows + const activeFlowRelationCategory = activeFlows.map((flow) => { + return { + objectID: flow.id, + objectType: 'flow' as 'plan', + categoryID: createBrandedValue(136), + }; + }); + + const pendingFlowRelationCategory = pendingFlows.map((flow) => { + return { + objectID: flow.id, + objectType: 'flow' as 'plan', + categoryID: createBrandedValue(45), + }; + }); + + await models.categoryRef.createMany(activeFlowRelationCategory); + await models.categoryRef.createMany(pendingFlowRelationCategory); + }); + + afterAll(async () => { + const connection = ContextProvider.Instance.conn; + await connection.table('flow').del(); + await connection.table('category').del(); + await connection.table('categoryRef').del(); + await connection.table('categoryGroup').del(); + }); + + test('All data should be returned (full query) [pending = false]', async () => { + const response = + await ContextProvider.Instance.apolloTestServer.executeOperation({ + query: buildFullQuery( + defaultPageSize, + defaultSortField, + defaultSortOrder, + false + ), + }); + + validateSearchFlowResponse(response); + + const data = response.data as SearchFlowGQLResponse; + + validateSearchFlowResponseData(data); + + const searchFlowsResponse: FlowSearchResult = data.searchFlows; + const flows = searchFlowsResponse.flows; + + validateFlowResponseFullQuery(flows); + }); + + test('All data should be returned (simpleQuery) [pending = false]', async () => { + const response = + await ContextProvider.Instance.apolloTestServer.executeOperation({ + query: buildSimpleQuery( + defaultPageSize, + defaultSortField, + defaultSortOrder, + false + ), + }); + + validateSearchFlowResponse(response); + + const data = response.data as SearchFlowGQLResponse; + + validateSearchFlowResponseData(data); + + const searchFlowsResponse: FlowSearchResult = data.searchFlows; + const flows = searchFlowsResponse.flows; + + validateFlowResponseSimpleQuery(flows); + }); + + test('All data should be returned (full query) [pending = true]', async () => { + const response = + await ContextProvider.Instance.apolloTestServer.executeOperation({ + query: buildFullQuery( + defaultPageSize, + defaultSortField, + defaultSortOrder, + true + ), + }); + + validateSearchFlowResponse(response); + + const data = response.data as SearchFlowGQLResponse; + + validateSearchFlowResponseData(data); + + const searchFlowsResponse: FlowSearchResult = data.searchFlows; + const flows = searchFlowsResponse.flows; + validateFlowResponseFullQuery(flows); + }); + + test('All data should be returned (simpleQuery) [pending = true]', async () => { + const response = + await ContextProvider.Instance.apolloTestServer.executeOperation({ + query: buildSimpleQuery( + defaultPageSize, + defaultSortField, + defaultSortOrder, + true + ), + }); + + validateSearchFlowResponse(response); + + const data = response.data as SearchFlowGQLResponse; + + validateSearchFlowResponseData(data); + + const searchFlowsResponse: FlowSearchResult = data.searchFlows; + const flows = searchFlowsResponse.flows; + validateFlowResponseSimpleQuery(flows); + }); + + function validateSearchFlowResponse(response: GraphQLResponse) { + expect(response).toBeDefined(); + expect(response.errors).toBeUndefined(); + expect(response.data).toBeDefined(); + } + + function validateSearchFlowResponseData(data: SearchFlowGQLResponse) { + expect(data.searchFlows).toBeDefined(); + + const searchFlowsResponse: FlowSearchResult = data.searchFlows; + expect(searchFlowsResponse.pageSize).toBe(defaultPageSize); + expect(searchFlowsResponse.hasPreviousPage).toBeDefined(); + expect(searchFlowsResponse.hasNextPage).toBeDefined(); + expect(searchFlowsResponse.nextPageCursor).toBeDefined(); + expect(searchFlowsResponse.prevPageCursor).toBeDefined(); + expect(searchFlowsResponse.total).toBeDefined(); + expect(searchFlowsResponse.flows).toBeDefined(); + } + + function validateFlowResponseFullQuery(flows: Flow[]) { + expect(flows.length).toBeLessThanOrEqual(defaultPageSize); + expect(flows.length).toBeGreaterThan(0); + + // We can get at least the first + const flow = flows[0]; + + expect(flow.id).toBeDefined(); + expect(flow.updatedAt).toBeDefined(); + expect(flow.amountUSD).toBeDefined(); + expect(flow.categories).toBeDefined(); + expect(flow.categories.length).toBeGreaterThan(0); + expect(flow.organizations).toBeDefined(); + expect(flow.locations).toBeDefined(); + expect(flow.plans).toBeDefined(); + expect(flow.usageYears).toBeDefined(); + } + + function validateFlowResponseSimpleQuery(flows: Flow[]) { + expect(flows.length).toBeLessThanOrEqual(defaultPageSize); + expect(flows.length).toBeGreaterThan(0); + // We can get at least the first + const flow = flows[0]; + + expect(flow.id).toBeDefined(); + expect(flow.updatedAt).toBeDefined(); + expect(flow.amountUSD).toBeDefined(); + + expect(flow.categories).toBeUndefined(); + expect(flow.organizations).toBeUndefined(); + expect(flow.locations).toBeUndefined(); + expect(flow.plans).toBeUndefined(); + expect(flow.usageYears).toBeUndefined(); + } +}); + +describe('GraphQL does not return data but error', () => { + test('Should return error when invalid sort field', async () => { + const response = + await ContextProvider.Instance.apolloTestServer.executeOperation({ + query: buildSimpleQuery(defaultPageSize, 'invalid', defaultSortOrder), + }); + + validateGraphQLResponseError(response); + }); + + test('Should return error when invalid sort order', async () => { + const response = + await ContextProvider.Instance.apolloTestServer.executeOperation({ + query: buildSimpleQuery(defaultPageSize, defaultSortField, 'invalid'), + }); + + validateGraphQLResponseError(response); + }); + + test('Should return error when no limit is provided', async () => { + const response = + await ContextProvider.Instance.apolloTestServer.executeOperation({ + query: buildSimpleQuery(null, defaultSortField, defaultSortOrder), + }); + + validateGraphQLResponseError(response); + }); + + function validateGraphQLResponseError(response: GraphQLResponse) { + expect(response).toBeDefined(); + expect(response.errors).toBeDefined(); + expect(response.data).toBeUndefined(); + } +}); diff --git a/tests/resolvers/software-info.spec.ts b/tests/resolvers/software-info.spec.ts index 004c7586..2b75336e 100644 --- a/tests/resolvers/software-info.spec.ts +++ b/tests/resolvers/software-info.spec.ts @@ -39,11 +39,11 @@ const testSoftwareInfo = }; describe('Query should return Software info', () => { - it('All data should be returned', testSoftwareInfo(true, true, true)); + test('All data should be returned', testSoftwareInfo(true, true, true)); - it('Only version should be returned', testSoftwareInfo(true, false, false)); + test('Only version should be returned', testSoftwareInfo(true, false, false)); - it('Only title should be returned', testSoftwareInfo(false, true, false)); + test('Only title should be returned', testSoftwareInfo(false, true, false)); - it('Only status should be returned', testSoftwareInfo(false, false, true)); + test('Only status should be returned', testSoftwareInfo(false, false, true)); }); diff --git a/tests/services/flow-search-service.spec.ts b/tests/services/flow-search-service.spec.ts new file mode 100644 index 00000000..59da8788 --- /dev/null +++ b/tests/services/flow-search-service.spec.ts @@ -0,0 +1,29 @@ +import { SearchFlowsFilters } from '../../src/domain-services/flows/graphql/args'; +import { prepareFlowConditions } from '../../src/domain-services/flows/strategy/impl/utils'; + +describe('FlowSearchService', () => { + describe('PrepareFlowConditions', () => { + test('should prepare flow conditions with all filters set to undefined', () => { + const flowFilters = new SearchFlowsFilters(); + + const result = prepareFlowConditions(flowFilters); + + expect(result).toEqual({}); + }); + + test('should prepare flow conditions with some filters having falsy values', () => { + const flowFilters = new SearchFlowsFilters(); + flowFilters.id = []; + flowFilters.activeStatus = false; + flowFilters.amountUSD = 0; + + const result = prepareFlowConditions(flowFilters); + + expect(result).toEqual({ + id: [], + activeStatus: false, + amountUSD: 0, + }); + }); + }); +}); diff --git a/tests/services/flow-service.spec.ts b/tests/services/flow-service.spec.ts new file mode 100644 index 00000000..4f3638ad --- /dev/null +++ b/tests/services/flow-service.spec.ts @@ -0,0 +1,167 @@ +import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; +import { type EntityDirection } from '../../src/domain-services/base-types'; +import { FlowObjectService } from '../../src/domain-services/flow-object/flow-object-service'; +import { type FlowObjectType } from '../../src/domain-services/flow-object/model'; +import { FlowService } from '../../src/domain-services/flows/flow-service'; +import { type FlowOrderByWithSubEntity } from '../../src/domain-services/flows/model'; +import { buildOrderBy } from '../../src/domain-services/flows/strategy/impl/utils'; +import ContextProvider from '../testContext'; + +const context = ContextProvider.Instance; + +describe('Test flow service', () => { + const externalReferences = [ + { + systemID: 'CERF' as const, + flowID: createBrandedValue(1), + versionID: 1, + externalRecordID: '-1234', + externalRecordDate: new Date(), + }, + { + systemID: 'EDRIS' as const, + flowID: createBrandedValue(3), + versionID: 1, + externalRecordID: '829634', + externalRecordDate: new Date(), + }, + { + systemID: 'OCT' as const, + flowID: createBrandedValue(2), + versionID: 2, + externalRecordID: '1234', + externalRecordDate: new Date(), + }, + ]; + + const organizations = [ + { name: 'AAAA', abbreviation: 'A' }, + { name: 'CCCC', abbreviation: 'C' }, + { name: 'ZZZZ', abbreviation: 'Z' }, + ]; + const flowObjectsOrganizations = [ + { + flowID: createBrandedValue(1), + objectID: createBrandedValue(1), + versionID: 1, + objectType: 'organization' as FlowObjectType, + refDirection: 'source' as EntityDirection, + }, + { + flowID: createBrandedValue(1), + objectID: createBrandedValue(2), + versionID: 1, + objectType: 'organization' as FlowObjectType, + refDirection: 'destination' as EntityDirection, + }, + { + flowID: createBrandedValue(2), + objectID: createBrandedValue(2), + versionID: 1, + objectType: 'organization' as FlowObjectType, + refDirection: 'source' as EntityDirection, + }, + { + flowID: createBrandedValue(2), + objectID: createBrandedValue(3), + versionID: 1, + objectType: 'organization' as FlowObjectType, + refDirection: 'destination' as EntityDirection, + }, + ]; + beforeAll(async () => { + // Create externalReferences + await context.models.externalReference.createMany(externalReferences); + + // Create organizations + const createdOrganization = + await context.models.organization.createMany(organizations); + + // Update flowObjects with organization IDs + flowObjectsOrganizations[0].objectID = createdOrganization[0].id; + flowObjectsOrganizations[1].objectID = createdOrganization[1].id; + flowObjectsOrganizations[2].objectID = createdOrganization[1].id; + flowObjectsOrganizations[3].objectID = createdOrganization[2].id; + + // Create flowObjects + await context.models.flowObject.createMany(flowObjectsOrganizations); + }); + + afterAll(async () => { + // Delete externalReference + await context.conn.table('externalReference').del(); + }); + describe('Test getFlowIDsFromEntity', () => { + const flowService = new FlowService(new FlowObjectService()); + + it("Case 1.1: if entity is 'externalReference' and order 'asc'", async () => { + const orderBy: FlowOrderByWithSubEntity = buildOrderBy( + 'externalReference.systemID', + 'asc' + ); + + const result = await flowService.getFlowIDsFromEntity( + context.models, + orderBy + ); + + expect(result).toBeTruthy(); + expect(result.length).toBe(3); + // Since order is asc, the first element should be 'CERF' + expect(result[0]).toEqual(externalReferences[0].flowID); + }); + + it("Case 1.2: if entity is 'externalReference' and order 'desc'", async () => { + const orderBy: FlowOrderByWithSubEntity = buildOrderBy( + 'externalReference.systemID', + 'desc' + ); + + const result = await flowService.getFlowIDsFromEntity( + context.models, + orderBy + ); + + expect(result).toBeTruthy(); + expect(result.length).toBe(3); + // Since order is desc, the first element should be 'OCT' + expect(result[0]).toEqual(externalReferences[3].flowID); + }); + + it("Case 2.1: if entity is a flowObject 'objectType' and order 'asc'", async () => { + const orderBy: FlowOrderByWithSubEntity = buildOrderBy( + 'organization.source.name', + 'asc' + ); + + const result = await flowService.getFlowIDsFromEntity( + context.models, + orderBy + ); + + expect(result).toBeTruthy(); + expect(result.length).toBe(4); + + // Since order is asc, the first element should be 'AAAA' + expect(result[0]).toEqual(flowObjectsOrganizations[0].flowID); + }); + + it("Case 2.2: if entity is a flowObject 'objectType' and order 'desc'", async () => { + const orderBy: FlowOrderByWithSubEntity = buildOrderBy( + 'organization.source.name', + 'desc' + ); + + const result = await flowService.getFlowIDsFromEntity( + context.models, + orderBy + ); + + expect(result).toBeTruthy(); + expect(result.length).toBe(4); + + // Since order is desc, the first element should be 'ZZZZ' + expect(result[0]).toEqual(flowObjectsOrganizations[4].flowID); + }); + }); +}); diff --git a/tests/utils/connection.ts b/tests/utils/connection.ts index 0cc28472..058473a8 100644 --- a/tests/utils/connection.ts +++ b/tests/utils/connection.ts @@ -24,7 +24,7 @@ export async function createDbConnection(connection: ConnectionConfig) { return knexInstance; } catch (error) { - console.log(error); + console.error(error); throw new Error( 'Unable to connect to Postgres via Knex. Ensure a valid connection.' ); diff --git a/yarn.lock b/yarn.lock index c3794ea3..4934790a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1365,18 +1365,18 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@^29.5.13": - version "29.5.13" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.13.tgz#8bc571659f401e6a719a7bf0dbcb8b78c71a8adc" - integrity sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg== +"@types/jest@^29.5.14": + version "29.5.14" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.14.tgz#2b910912fa1d6856cadcd0c1f95af7df1d6049e5" + integrity sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ== dependencies: expect "^29.0.0" pretty-format "^29.0.0" -"@types/lodash@^4.17.10": - version "4.17.10" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.10.tgz#64f3edf656af2fe59e7278b73d3e62404144a6e6" - integrity sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ== +"@types/lodash@^4.17.13": + version "4.17.13" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.13.tgz#786e2d67cfd95e32862143abe7463a7f90c300eb" + integrity sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg== "@types/long@^4.0.0": version "4.0.2" @@ -1401,12 +1401,12 @@ "@types/node" "*" form-data "^4.0.0" -"@types/node@*", "@types/node@^22.7.5": - version "22.7.5" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.5.tgz#cfde981727a7ab3611a481510b473ae54442b92b" - integrity sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ== +"@types/node@*", "@types/node@^22.10.1": + version "22.10.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.1.tgz#41ffeee127b8975a05f8c4f83fb89bcb2987d766" + integrity sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ== dependencies: - undici-types "~6.19.2" + undici-types "~6.20.0" "@types/node@^10.1.0": version "10.17.60" @@ -1550,19 +1550,19 @@ "@typescript-eslint/types" "8.4.0" eslint-visitor-keys "^3.4.3" -"@unocha/hpc-api-core@^10.1.1": - version "10.1.1" - resolved "https://registry.yarnpkg.com/@unocha/hpc-api-core/-/hpc-api-core-10.1.1.tgz#7a85c4afc4bb43b52e83ffdd83e58e03886940b9" - integrity sha512-BOGKuzCd7iCZ9qItHw/qaAB0VSYPGUYzUHoI7EgSGfQL0mBz8r+vKvyf+d3CZ6pbG6Hw0kagAMvCQa1Smdrjag== +"@unocha/hpc-api-core@^10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@unocha/hpc-api-core/-/hpc-api-core-10.6.0.tgz#c0a8f87127246a719cfc401b2aeb72d2ee681cfa" + integrity sha512-k+LsGdawKs82nENLk1iEamKMn63+VrSWiooCJYp3t3obIVcUEr40IT7xP/+pm9Pc7XT5P31/CTYqvjNJtRpflQ== dependencies: - "@types/lodash" "^4.17.10" + "@types/lodash" "^4.17.13" "@types/node-fetch" "2.6.11" fp-ts "^2.14.0" io-ts "2.2.20" knex "3.1.0" lodash "^4.17.21" node-fetch "2.7.0" - pg "^8.13.0" + pg "^8.13.1" ts-node "^10.9.2" "@unocha/hpc-repo-tools@^5.0.0": @@ -1691,14 +1691,6 @@ anymatch@^3.0.3, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -anymatch@~3.1.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" - integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== - dependencies: - normalize-path "^3.0.0" - picomatch "^2.0.4" - apollo-datasource@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/apollo-datasource/-/apollo-datasource-3.3.2.tgz#5711f8b38d4b7b53fb788cb4dbd4a6a526ea74c8" @@ -1820,12 +1812,7 @@ async@^2.6.3, async@~2.6.1: dependencies: lodash "^4.17.14" -async@^3.2.0, async@~3.2.0: - version "3.2.2" - resolved "https://registry.yarnpkg.com/async/-/async-3.2.2.tgz#2eb7671034bb2194d45d30e31e24ec7e7f9670cd" - integrity sha512-H0E+qZaDEfx/FY4t7iLRv1W2fFI6+pyCeTw1uN20AQPiwqwM6ojPxHxdLv4z8hi2DtnW9BOckSspLucW7pIE5g== - -async@^3.2.3: +async@^3.2.0, async@^3.2.3, async@~3.2.0: version "3.2.6" resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== @@ -2061,22 +2048,7 @@ charm@~0.1.1: resolved "https://registry.yarnpkg.com/charm/-/charm-0.1.2.tgz#06c21eed1a1b06aeb67553cdc53e23274bac2296" integrity sha1-BsIe7RobBq62dVPNxT4jJ0usIpY= -chokidar@^3.5.1: - version "3.5.1" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" - integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw== - dependencies: - anymatch "~3.1.1" - braces "~3.0.2" - glob-parent "~5.1.0" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.5.0" - optionalDependencies: - fsevents "~2.3.1" - -chokidar@^3.5.3: +chokidar@^3.5.1, chokidar@^3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== @@ -2270,9 +2242,9 @@ croner@~4.1.92: integrity sha512-/f6gpQuxDaqXu+1kwQYSckUglPaOrHdbIlBAu0YuW8/Cdb45XwXYNUBXg3r/9Mo6n540Kn/smKcZWko5x99KrQ== cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" @@ -2835,7 +2807,7 @@ fsevents@^2.3.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== -fsevents@~2.3.1, fsevents@~2.3.2: +fsevents@~2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -2915,7 +2887,7 @@ git-sha1@^0.1.2: resolved "https://registry.yarnpkg.com/git-sha1/-/git-sha1-0.1.2.tgz#599ac192b71875825e13a445f3a6e05118c2f745" integrity sha1-WZrBkrcYdYJeE6RF86bgURjC90U= -glob-parent@^5.1.2, glob-parent@~5.1.0, glob-parent@~5.1.2: +glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -3092,10 +3064,10 @@ human-signals@^5.0.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-5.0.0.tgz#42665a284f9ae0dade3ba41ebc37eb4b852f3a28" integrity sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ== -husky@^9.1.6: - version "9.1.6" - resolved "https://registry.yarnpkg.com/husky/-/husky-9.1.6.tgz#e23aa996b6203ab33534bdc82306b0cf2cb07d6c" - integrity sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A== +husky@^9.1.7: + version "9.1.7" + resolved "https://registry.yarnpkg.com/husky/-/husky-9.1.7.tgz#d46a38035d101b46a70456a850ff4201344c0b2d" + integrity sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA== iconv-lite@0.4.24, iconv-lite@^0.4.4: version "0.4.24" @@ -4435,10 +4407,10 @@ pg-types@^4.0.1: postgres-interval "^3.0.0" postgres-range "^1.1.1" -pg@^8.12.0, pg@^8.13.0: - version "8.13.0" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.13.0.tgz#e3d245342eb0158112553fcc1890a60720ae2a3d" - integrity sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw== +pg@^8.13.1: + version "8.13.1" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.13.1.tgz#6498d8b0a87ff76c2df7a32160309d3168c0c080" + integrity sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ== dependencies: pg-connection-string "^2.7.0" pg-pool "^3.7.0" @@ -4554,10 +4526,10 @@ pm2-sysmonit@^1.2.8: systeminformation "^5.7" tx2 "~1.0.4" -pm2@^5.4.2: - version "5.4.2" - resolved "https://registry.yarnpkg.com/pm2/-/pm2-5.4.2.tgz#34a50044cf772c5528d68e2713f84383ebb2e09b" - integrity sha512-ynVpBwZampRH3YWLwRepZpQ7X3MvpwLIaqIdFEeBYEhaXbHmEx2KqOdxGV4T54wvKBhH3LixvU1j1bK4/sq7Tw== +pm2@^5.4.3: + version "5.4.3" + resolved "https://registry.yarnpkg.com/pm2/-/pm2-5.4.3.tgz#4d8c456c7aa2f5dd59714fd130e9c2f1d924e86e" + integrity sha512-4/I1htIHzZk1Y67UgOCo4F1cJtas1kSds31N8zN0PybO230id1nigyjGuGFzUnGmUFPmrJ0On22fO1ChFlp7VQ== dependencies: "@pm2/agent" "~2.0.0" "@pm2/io" "~6.0.1" @@ -4655,10 +4627,10 @@ prettier-plugin-organize-imports@4.0.0: resolved "https://registry.yarnpkg.com/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.0.0.tgz#a69acf024ea3c8eb650c81f664693826ca853534" integrity sha512-vnKSdgv9aOlqKeEFGhf9SCBsTyzDSyScy1k7E0R1Uo4L0cTcOV7c1XQaT7jfXIOc/p08WLBfN2QUQA9zDSZMxA== -prettier@3.3.3: - version "3.3.3" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105" - integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew== +prettier@3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.4.1.tgz#e211d451d6452db0a291672ca9154bc8c2579f7b" + integrity sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg== pretty-format@^29.0.0, pretty-format@^29.7.0: version "29.7.0" @@ -4778,13 +4750,6 @@ readable-stream@^3.0.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" -readdirp@~3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" - integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ== - dependencies: - picomatch "^2.2.1" - readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -4948,14 +4913,7 @@ semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.2, semver@^7.3.2, semver@^7.5.3, semver@^7.5.4, semver@~7.5.4: - version "7.5.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" - integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== - dependencies: - lru-cache "^6.0.0" - -semver@^7.6.0, semver@^7.6.1, semver@^7.6.3: +semver@^7.2, semver@^7.3.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.1, semver@^7.6.3: version "7.6.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== @@ -4965,6 +4923,13 @@ semver@~7.2.0: resolved "https://registry.yarnpkg.com/semver/-/semver-7.2.3.tgz#3641217233c6382173c76bf2c7ecd1e1c16b0d8a" integrity sha512-utbW9Z7ZxVvwiIWkdOMLOR9G/NFXh2aRucghkVrEMJWuC++r3lCkBC3LwqBinyHzGMAJxY5tn6VakZGHObq5ig== +semver@~7.5.4: + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== + dependencies: + lru-cache "^6.0.0" + setprototypeof@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" @@ -5066,7 +5031,7 @@ source-map-support@0.5.13: buffer-from "^1.0.0" source-map "^0.6.0" -source-map-support@0.5.21: +source-map-support@0.5.21, source-map-support@^0.5.12: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== @@ -5074,14 +5039,6 @@ source-map-support@0.5.21: buffer-from "^1.0.0" source-map "^0.6.0" -source-map-support@^0.5.12: - version "0.5.19" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" - integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" @@ -5482,10 +5439,10 @@ typescript@^5.5.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== -undici-types@~6.19.2: - version "6.19.8" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" - integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== +undici-types@~6.20.0: + version "6.20.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" + integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== universalify@^0.1.0: version "0.1.2"