From 7748017e7af5157a79d010a781c47fd66e501772 Mon Sep 17 00:00:00 2001 From: manelcecs Date: Wed, 24 Apr 2024 11:44:36 +0200 Subject: [PATCH 1/7] Basic types for GraphQL resolvers Upgrade hpc-api-core with latest develop version --- package.json | 2 +- src/domain-services/base-types.ts | 15 ++++++--- src/utils/database-types.ts | 16 ++++++++++ src/utils/graphql/pagination.ts | 52 +++++++++++++++++++++++++++++++ yarn.lock | 22 ++++++------- 5 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 src/utils/database-types.ts create mode 100644 src/utils/graphql/pagination.ts diff --git a/package.json b/package.json index 3e68eaa4..e291b19a 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "lint": "yarn lint-prettier && yarn lint-eslint" }, "dependencies": { - "@unocha/hpc-api-core": "^10.1.1", + "@unocha/hpc-api-core": "^10.5.0", "apollo-server-hapi": "^3.13.0", "bunyan": "^1.8.15", "class-validator": "^0.14.1", 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/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/yarn.lock b/yarn.lock index 4e8723bf..723a8fae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1373,10 +1373,10 @@ 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" @@ -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.5.0": + version "10.5.0" + resolved "https://registry.yarnpkg.com/@unocha/hpc-api-core/-/hpc-api-core-10.5.0.tgz#3bb1f41f01a61f8794155b6fb6e4ab8d2755cad0" + integrity sha512-59IGHbiL/Y0RAbZ3F5bCZDTEaypS/ET7IUCElhqHosWOdL2rw+dWuBFsHAXMyb3DbTXuWTpmJpaUFi4Fm4+U7g== 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": @@ -4435,7 +4435,7 @@ pg-types@^4.0.1: postgres-interval "^3.0.0" postgres-range "^1.1.1" -pg@^8.13.0, pg@^8.13.1: +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== From d5ecf500b608de2e7ab198e52dcd917e570e40df Mon Sep 17 00:00:00 2001 From: manelcecs Date: Wed, 24 Apr 2024 12:21:31 +0200 Subject: [PATCH 2/7] Flow resolver and Args Needed nested properties GQL types merge 6c551774dedb9dceb0f3d29084ea8959bcfa6d6b --- .../categories/graphql/types.ts | 47 ++++ src/domain-services/flows/graphql/args.ts | 158 ++++++++++++++ src/domain-services/flows/graphql/resolver.ts | 32 +++ src/domain-services/flows/graphql/types.ts | 200 ++++++++++++++++++ src/domain-services/location/graphql/types.ts | 49 ++++- .../organizations/graphql/types.ts | 41 ++++ src/domain-services/plans/graphql/types.ts | 50 ++++- .../report-details/graphql/types.ts | 41 ++++ 8 files changed, 611 insertions(+), 7 deletions(-) create mode 100644 src/domain-services/categories/graphql/types.ts create mode 100644 src/domain-services/flows/graphql/args.ts create mode 100644 src/domain-services/flows/graphql/resolver.ts create mode 100644 src/domain-services/flows/graphql/types.ts create mode 100644 src/domain-services/organizations/graphql/types.ts create mode 100644 src/domain-services/report-details/graphql/types.ts 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/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/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/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/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/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; From 9e3e438134b4393ec992cd5ffe18d773b899a73e Mon Sep 17 00:00:00 2001 From: manelcecs Date: Wed, 24 Apr 2024 12:30:06 +0200 Subject: [PATCH 3/7] Flow search services Nested needed services to complete Flow response --- .../categories/category-service.ts | 267 +++++++++ src/domain-services/categories/model.ts | 7 + .../external-reference-service.ts | 95 +++ .../flow-link/flow-link-service.ts | 39 ++ .../flow-object/flow-object-service.ts | 145 +++++ src/domain-services/flow-object/model.ts | 14 + src/domain-services/flow-object/utils.ts | 33 ++ .../flows/flow-search-service.ts | 552 ++++++++++++++++++ src/domain-services/flows/flow-service.ts | 500 ++++++++++++++++ src/domain-services/flows/model.ts | 33 ++ .../flows/strategy/flow-search-strategy.ts | 33 ++ src/domain-services/legacy/legacy-service.ts | 24 + .../location/location-service.ts | 71 +++ .../organizations/organization-service.ts | 97 +++ src/domain-services/plans/plan-service.ts | 85 +++ .../report-details/report-detail-service.ts | 128 ++++ .../usage-years/graphql/types.ts | 8 + .../usage-years/usage-year-service.ts | 72 +++ 18 files changed, 2203 insertions(+) create mode 100644 src/domain-services/categories/category-service.ts create mode 100644 src/domain-services/categories/model.ts create mode 100644 src/domain-services/external-reference/external-reference-service.ts create mode 100644 src/domain-services/flow-link/flow-link-service.ts create mode 100644 src/domain-services/flow-object/flow-object-service.ts create mode 100644 src/domain-services/flow-object/model.ts create mode 100644 src/domain-services/flow-object/utils.ts create mode 100644 src/domain-services/flows/flow-search-service.ts create mode 100644 src/domain-services/flows/flow-service.ts create mode 100644 src/domain-services/flows/model.ts create mode 100644 src/domain-services/flows/strategy/flow-search-strategy.ts create mode 100644 src/domain-services/legacy/legacy-service.ts create mode 100644 src/domain-services/organizations/organization-service.ts create mode 100644 src/domain-services/report-details/report-detail-service.ts create mode 100644 src/domain-services/usage-years/graphql/types.ts create mode 100644 src/domain-services/usage-years/usage-year-service.ts 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/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..83229429 --- /dev/null +++ b/src/domain-services/flow-object/flow-object-service.ts @@ -0,0 +1,145 @@ +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 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 OrderBy } from '../../utils/database-types'; +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 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?: OrderBy + ): 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?: OrderBy + ): 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..a552cf95 --- /dev/null +++ b/src/domain-services/flows/flow-service.ts @@ -0,0 +1,500 @@ +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 } 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, + mapOrderByToEntityOrderBy, +} 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; + // Get the entity list + const mappedOrderBy = mapOrderByToEntityOrderBy(orderBy); + // 'externalReference' is a special case + // because it does have a direct relation with flow + // and no direction + if (entity === 'externalReference') { + const column = mappedOrderBy.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'; + + // Validate the variable using io-ts + + let flowObjects = []; + let entityIDsSorted: number[] = []; + + switch (entity) { + case 'emergency': { + // Get emergency entities sorted + const emergencies = await database.emergency.find({ + distinct: [mappedOrderBy.column, 'id'], + orderBy: mappedOrderBy, + }); + + entityIDsSorted = emergencies.map((emergency) => + emergency.id.valueOf() + ); + break; + } + case 'globalCluster': { + // Get globalCluster entities sorted + const globalClusters = await database.globalCluster.find({ + distinct: [mappedOrderBy.column, 'id'], + orderBy: mappedOrderBy, + }); + + entityIDsSorted = globalClusters.map((globalCluster) => + globalCluster.id.valueOf() + ); + break; + } + case 'governingEntity': { + // Get governingEntity entities sorted + const governingEntities = await database.governingEntity.find({ + distinct: [mappedOrderBy.column, 'id'], + orderBy: mappedOrderBy, + }); + + entityIDsSorted = governingEntities.map((governingEntity) => + governingEntity.id.valueOf() + ); + break; + } + case 'location': { + // Get location entities sorted + const locations = await database.location.find({ + distinct: [mappedOrderBy.column, 'id'], + orderBy: mappedOrderBy, + }); + + entityIDsSorted = locations.map((location) => location.id.valueOf()); + break; + } + case 'organization': { + // Get organization entities sorted + const organizations = await database.organization.find({ + distinct: [mappedOrderBy.column, 'id'], + orderBy: mappedOrderBy, + }); + + entityIDsSorted = organizations.map((organization) => + organization.id.valueOf() + ); + break; + } + case 'plan': { + // Get plan entities sorted + const plans = await database.plan.find({ + distinct: [mappedOrderBy.column, 'id'], + orderBy: mappedOrderBy, + }); + + entityIDsSorted = plans.map((plan) => plan.id.valueOf()); + break; + } + case 'project': { + // Get project entities sorted + const projects = await database.project.find({ + distinct: [mappedOrderBy.column, 'id'], + orderBy: mappedOrderBy, + }); + + entityIDsSorted = projects.map((project) => project.id.valueOf()); + break; + } + case 'usageYear': { + // Get usageYear entities sorted + const usageYears = await database.usageYear.find({ + distinct: [mappedOrderBy.column, 'id'], + orderBy: mappedOrderBy, + }); + + entityIDsSorted = usageYears.map((usageYear) => usageYear.id.valueOf()); + break; + } + case 'planVersion': { + // 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 planVersions = await database.planVersion.find({ + distinct: [mappedOrderBy.column, entityKey], + orderBy: mappedOrderBy, + }); + + 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/model.ts b/src/domain-services/flows/model.ts new file mode 100644 index 00000000..4a6850e7 --- /dev/null +++ b/src/domain-services/flows/model.ts @@ -0,0 +1,33 @@ +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/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/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/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/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/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(), + }; + } +} From 2782b3208b1bc0bf4c4e817102a5b92c8996213d Mon Sep 17 00:00:00 2001 From: manelcecs Date: Wed, 24 Apr 2024 12:31:55 +0200 Subject: [PATCH 4/7] Add 'only-flow-conditions-strategy' implementation Add also utils to operate over flows --- .../flow-object/flow-object-service.ts | 13 +- src/domain-services/flows/flow-service.ts | 89 +++- src/domain-services/flows/model.ts | 7 +- .../only-flow-conditions-strategy-impl.ts | 65 +++ .../flows/strategy/impl/utils.ts | 412 ++++++++++++++++++ 5 files changed, 556 insertions(+), 30 deletions(-) create mode 100644 src/domain-services/flows/strategy/impl/only-flow-conditions-strategy-impl.ts create mode 100644 src/domain-services/flows/strategy/impl/utils.ts diff --git a/src/domain-services/flow-object/flow-object-service.ts b/src/domain-services/flow-object/flow-object-service.ts index 83229429..da97b58e 100644 --- a/src/domain-services/flow-object/flow-object-service.ts +++ b/src/domain-services/flow-object/flow-object-service.ts @@ -4,10 +4,13 @@ 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 { 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 OrderBy } from '../../utils/database-types'; import { type UniqueFlowEntity } from '../flows/model'; import { buildSearchFlowsObjectConditions } from '../flows/strategy/impl/utils'; import { type FlowObjectFilterGrouped } from './model'; @@ -16,6 +19,8 @@ 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 { @@ -75,7 +80,7 @@ export class FlowObjectService { async getFlowsObjectsByFlows( models: Database, whereClauses: FlowObjectWhere, - orderBy?: OrderBy + orderBy?: FlowObjectOrderByCond ): Promise { const distinctColumns: Array = [ 'flowID', @@ -104,7 +109,7 @@ export class FlowObjectService { stopOnBatchSize: boolean, responseList: FlowObjectInstance[], flowObjectsWhere: FlowObjectWhere, - orderBy?: OrderBy + orderBy?: FlowObjectOrderByCond ): Promise { const reducedFlows = referenceList.slice(offset, offset + batchSize); diff --git a/src/domain-services/flows/flow-service.ts b/src/domain-services/flows/flow-service.ts index a552cf95..2aaa3cd3 100644 --- a/src/domain-services/flows/flow-service.ts +++ b/src/domain-services/flows/flow-service.ts @@ -20,10 +20,7 @@ import type { IGetFlowsArgs, UniqueFlowEntity, } from './model'; -import { - buildSearchFlowsConditions, - mapOrderByToEntityOrderBy, -} from './strategy/impl/utils'; +import { buildSearchFlowsConditions } from './strategy/impl/utils'; @Service() export class FlowService { @@ -56,12 +53,11 @@ export class FlowService { ): Promise { const entity = orderBy.subEntity ?? orderBy.entity; // Get the entity list - const mappedOrderBy = mapOrderByToEntityOrderBy(orderBy); // 'externalReference' is a special case // because it does have a direct relation with flow // and no direction if (entity === 'externalReference') { - const column = mappedOrderBy.column as keyof InstanceOfModel< + const column = orderBy.column as keyof InstanceOfModel< Database['externalReference'] >; const externalReferences = await database.externalReference.find({ @@ -90,9 +86,14 @@ export class FlowService { switch (entity) { case 'emergency': { // 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: [mappedOrderBy.column, 'id'], - orderBy: mappedOrderBy, + distinct: [column, 'id'], + orderBy: orderByEmergency, }); entityIDsSorted = emergencies.map((emergency) => @@ -102,9 +103,14 @@ export class FlowService { } case 'globalCluster': { // 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: [mappedOrderBy.column, 'id'], - orderBy: mappedOrderBy, + distinct: [column, 'id'], + orderBy: orderByGlobalCluster, }); entityIDsSorted = globalClusters.map((globalCluster) => @@ -114,9 +120,14 @@ export class FlowService { } case 'governingEntity': { // 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: [mappedOrderBy.column, 'id'], - orderBy: mappedOrderBy, + distinct: [column, 'id'], + orderBy: orderByGoverningEntity, }); entityIDsSorted = governingEntities.map((governingEntity) => @@ -126,9 +137,14 @@ export class FlowService { } case 'location': { // 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: [mappedOrderBy.column, 'id'], - orderBy: mappedOrderBy, + distinct: [column, 'id'], + orderBy: orderByLocation, }); entityIDsSorted = locations.map((location) => location.id.valueOf()); @@ -136,9 +152,14 @@ export class FlowService { } case 'organization': { // 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: [mappedOrderBy.column, 'id'], - orderBy: mappedOrderBy, + distinct: [column, 'id'], + orderBy: orderByOrganization, }); entityIDsSorted = organizations.map((organization) => @@ -148,9 +169,14 @@ export class FlowService { } case 'plan': { // 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: [mappedOrderBy.column, 'id'], - orderBy: mappedOrderBy, + distinct: [column, 'id'], + orderBy: orderByPlan, }); entityIDsSorted = plans.map((plan) => plan.id.valueOf()); @@ -158,9 +184,14 @@ export class FlowService { } case 'project': { // 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: [mappedOrderBy.column, 'id'], - orderBy: mappedOrderBy, + distinct: [column, 'id'], + orderBy: orderByProject, }); entityIDsSorted = projects.map((project) => project.id.valueOf()); @@ -168,9 +199,14 @@ export class FlowService { } case 'usageYear': { // 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: [mappedOrderBy.column, 'id'], - orderBy: mappedOrderBy, + distinct: [column, 'id'], + orderBy: orderByUsageYear, }); entityIDsSorted = usageYears.map((usageYear) => usageYear.id.valueOf()); @@ -183,9 +219,14 @@ export class FlowService { 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: [mappedOrderBy.column, entityKey], - orderBy: mappedOrderBy, + distinct: [column, entityKey], + orderBy: orderByPlanVersion, }); entityIDsSorted = planVersions.map((planVersion) => diff --git a/src/domain-services/flows/model.ts b/src/domain-services/flows/model.ts index 4a6850e7..1944ca10 100644 --- a/src/domain-services/flows/model.ts +++ b/src/domain-services/flows/model.ts @@ -2,14 +2,17 @@ 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 { + 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 FlowFieldsDefinition = FieldsOfModel; export type FlowOrderByCond = OrderByCond; // Can this be simplified somehow? export type UniqueFlowEntity = { id: FlowId; 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/utils.ts b/src/domain-services/flows/strategy/impl/utils.ts new file mode 100644 index 00000000..2322720f --- /dev/null +++ b/src/domain-services/flows/strategy/impl/utils.ts @@ -0,0 +1,412 @@ +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 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; +}; + +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 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?: 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: undefined, + entity: 'flow', + }; + + // 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; +}; From 27fae329a671eea522b69afd2f778763eda65730 Mon Sep 17 00:00:00 2001 From: manelcecs Date: Wed, 24 Apr 2024 12:33:15 +0200 Subject: [PATCH 5/7] Add 'search-flow-by-filters-strategy' implementation Nested strategies to find flowIDs from different filters Merge 5de9ca312a5fe37aeea198cbc6ba84bac352d3ea --- .../flows/strategy/flowID-search-strategy.ts | 21 ++ ...-flow-category-conditions-strategy-impl.ts | 100 +++++++ ...-from-nested-flow-filters-strategy-impl.ts | 102 +++++++ ...ds-flow-object-conditions-strategy-impl.ts | 57 ++++ .../search-flow-by-filters-strategy-impl.ts | 273 ++++++++++++++++++ 5 files changed, 553 insertions(+) create mode 100644 src/domain-services/flows/strategy/flowID-search-strategy.ts create mode 100644 src/domain-services/flows/strategy/impl/get-flowIds-flow-category-conditions-strategy-impl.ts create mode 100644 src/domain-services/flows/strategy/impl/get-flowIds-flow-from-nested-flow-filters-strategy-impl.ts create mode 100644 src/domain-services/flows/strategy/impl/get-flowIds-flow-object-conditions-strategy-impl.ts create mode 100644 src/domain-services/flows/strategy/impl/search-flow-by-filters-strategy-impl.ts 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/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 }; + } +} From e63c0d0824f6aae48083dfb4acf501374f5de05a Mon Sep 17 00:00:00 2001 From: manelcecs Date: Wed, 24 Apr 2024 12:33:43 +0200 Subject: [PATCH 6/7] Add unit tests to cover search flows Merge 141f445d9671365e135350796e120e0bdbf55ffc --- tests/resolvers/flows.spec.ts | 450 +++++++++++++++++++++ tests/resolvers/software-info.spec.ts | 8 +- tests/services/flow-search-service.spec.ts | 29 ++ tests/services/flow-service.spec.ts | 167 ++++++++ tests/utils/connection.ts | 2 +- 5 files changed, 651 insertions(+), 5 deletions(-) create mode 100644 tests/resolvers/flows.spec.ts create mode 100644 tests/services/flow-search-service.spec.ts create mode 100644 tests/services/flow-service.spec.ts 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.' ); From a94af2d88b16c509960933c9b8d4811b2b29d67e Mon Sep 17 00:00:00 2001 From: manelcecs Date: Tue, 12 Nov 2024 11:59:59 +0100 Subject: [PATCH 7/7] Add runtime checks for 'input' columns --- src/domain-services/flows/flow-service.ts | 80 ++++++++++++++++++- .../flows/strategy/impl/utils.ts | 12 ++- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/domain-services/flows/flow-service.ts b/src/domain-services/flows/flow-service.ts index 2aaa3cd3..1e485fd2 100644 --- a/src/domain-services/flows/flow-service.ts +++ b/src/domain-services/flows/flow-service.ts @@ -2,7 +2,10 @@ 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 } from '@unocha/hpc-api-core/src/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 { @@ -52,11 +55,20 @@ export class FlowService { 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'] >; @@ -78,17 +90,23 @@ export class FlowService { const refDirection = orderBy.direction ?? 'source'; - // Validate the variable using io-ts - 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({ @@ -102,6 +120,13 @@ export class FlowService { 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'] @@ -119,6 +144,13 @@ export class FlowService { 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'] @@ -136,6 +168,13 @@ export class FlowService { 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'] @@ -151,6 +190,13 @@ export class FlowService { 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'] @@ -168,6 +214,13 @@ export class FlowService { 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'] @@ -183,6 +236,13 @@ export class FlowService { 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'] @@ -198,6 +258,13 @@ export class FlowService { 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'] @@ -213,6 +280,13 @@ export class FlowService { 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 = `${ diff --git a/src/domain-services/flows/strategy/impl/utils.ts b/src/domain-services/flows/strategy/impl/utils.ts index 2322720f..07e54539 100644 --- a/src/domain-services/flows/strategy/impl/utils.ts +++ b/src/domain-services/flows/strategy/impl/utils.ts @@ -3,6 +3,7 @@ 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'; @@ -40,7 +41,8 @@ export const defaultSearchFlowFilter: FlowWhere = { type FlowOrderByCommon = { order: SortOrder; - direction?: EntityDirection; + direction: EntityDirection; + subEntity?: string; }; export type FlowOrderBy = FlowOrderByCommon & @@ -91,6 +93,8 @@ export type FlowOrderBy = FlowOrderByCommon & } ); +export type FlowOrderByCodec = t.Type; + export const mapFlowCategoryConditionsToWhereClause = ( flowCategoryConditions: FlowCategory[] ) => { @@ -150,7 +154,7 @@ export const mapFlowCategoryConditionsToWhereClause = ( }; export const mapFlowOrderBy = ( - orderBy?: FlowOrderByWithSubEntity + orderBy?: FlowOrderBy | FlowOrderByWithSubEntity ): OrderBy => { if (!orderBy || orderBy.entity !== 'flow') { return defaultFlowOrderBy(); @@ -371,9 +375,9 @@ export const buildOrderBy = ( const orderBy: FlowOrderByWithSubEntity = { column: sortField ?? 'updatedAt', order: sortOrder ?? ('desc' as SortOrder), - direction: undefined, + direction: 'source' as EntityDirection, entity: 'flow', - }; + } satisfies FlowOrderByWithSubEntity; // Check if sortField is a nested property if (orderBy.column.includes('.')) {