diff --git a/package-lock.json b/package-lock.json index 044ba777c..cabf95684 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "form-data": "^3.0.1", "google-spreadsheet": "^3.2.0", "graphql": "16.8.1", + "graphql-fields": "^2.0.3", "graphql-tag": "^2.12.6", "graphql-upload": "15.0.2", "handlebars": "4.7.7", @@ -11943,6 +11944,11 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/graphql-fields": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/graphql-fields/-/graphql-fields-2.0.3.tgz", + "integrity": "sha512-x3VE5lUcR4XCOxPIqaO4CE+bTK8u6gVouOdpQX9+EKHr+scqtK5Pp/l8nIGqIpN1TUlkKE6jDCCycm/WtLRAwA==" + }, "node_modules/graphql-query-complexity": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/graphql-query-complexity/-/graphql-query-complexity-0.12.0.tgz", diff --git a/package.json b/package.json index 5d1738dc9..4174475a4 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "form-data": "^3.0.1", "google-spreadsheet": "^3.2.0", "graphql": "16.8.1", + "graphql-fields": "^2.0.3", "graphql-tag": "^2.12.6", "graphql-upload": "15.0.2", "handlebars": "4.7.7", @@ -202,6 +203,7 @@ "test:bootstrap": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/server/bootstrap.test.ts", "test:utils": "NODE_ENV=test mocha ./src/utils/utils.test.ts", "start": "NODE_ENV=development ts-node-dev --project ./tsconfig.json --respawn ./src/index.ts", + "start:test": "NODE_ENV=development ts-node-dev --project ./tsconfig.json --respawn ./test.ts", "serve": "pm2 startOrRestart ecosystem.config.js --node-args='--max-old-space-size=8192'", "db:migrate:run:test": "NODE_ENV=test npx typeorm-ts-node-commonjs migration:run -d ./src/ormconfig.ts", "db:migrate:revert:test": "NODE_ENV=test npx typeorm-ts-node-commonjs migration:revert -d ./src/ormconfig.ts", diff --git a/src/repositories/projectRepository.ts b/src/repositories/projectRepository.ts index 8fee33896..4a669bbe0 100644 --- a/src/repositories/projectRepository.ts +++ b/src/repositories/projectRepository.ts @@ -324,6 +324,16 @@ export const findProjectBySlug = (slug: string): Promise => { ); }; +export const findProjectIdBySlug = (slug: string): Promise => { + // check current slug and previous slugs + return Project.createQueryBuilder('project') + .select('project.id') + .where(`:slug = ANY(project."slugHistory") or project.slug = :slug`, { + slug, + }) + .getOne(); +}; + export const findProjectBySlugWithoutAnyJoin = ( slug: string, ): Promise => { diff --git a/src/repositories/projectVerificationRepository.ts b/src/repositories/projectVerificationRepository.ts index febc002f2..c3299900f 100644 --- a/src/repositories/projectVerificationRepository.ts +++ b/src/repositories/projectVerificationRepository.ts @@ -328,6 +328,17 @@ export const updateManagingFundsOfProjectVerification = async (params: { return projectVerificationForm?.save(); }; +export const getVerificationFormStatusByProjectId = async ( + projectId: number, +): Promise => { + return ProjectVerificationForm.createQueryBuilder('project_verification_form') + .select(['project_verification_form.status']) + .where(`project_verification_form.projectId=:projectId`, { + projectId, + }) + .getOne(); +}; + export const getVerificationFormByProjectId = async ( projectId: number, ): Promise => { diff --git a/src/resolvers/projectResolver.test.ts b/src/resolvers/projectResolver.test.ts index 15e0ea811..5d82a7df0 100644 --- a/src/resolvers/projectResolver.test.ts +++ b/src/resolvers/projectResolver.test.ts @@ -4287,7 +4287,7 @@ function getProjectUpdatesTestCases() { } function projectBySlugTestCases() { - it('should return projects with indicated slug and verification form if owner', async () => { + it('should return projects with indicated slug and verification form status if owner', async () => { const project1 = await saveProjectDirectlyToDb({ ...createProjectData(), title: String(new Date().getTime()), @@ -4326,68 +4326,53 @@ function projectBySlugTestCases() { const project = result.data.data.projectBySlug; assert.equal(Number(project.id), project1.id); - assert.isOk(project.projectVerificationForm); - assert.equal(project.projectVerificationForm.id, verificationForm.id); + assert.isOk(project.verificationFormStatus); + assert.equal(project.verificationFormStatus, verificationForm.status); assert.isOk(project.adminUser.walletAddress); assert.isOk(project.adminUser.firstName); assert.isNotOk(project.adminUser.email); assert.isOk(project.categories[0].mainCategory.title); }); - it('should return verificationFormStatus if its not owner', async () => { - const project1 = await saveProjectDirectlyToDb({ - ...createProjectData(), - title: String(new Date().getTime()), - slug: String(new Date().getTime()), - }); - - const user = - (await User.findOne({ - where: { - id: project1.adminUserId, - }, - })) || undefined; - - const verificationForm = await ProjectVerificationForm.create({ - project: project1, - user, - status: PROJECT_VERIFICATION_STATUSES.DRAFT, - }).save(); - - const result = await axios.post(graphqlUrl, { - query: fetchProjectBySlugQuery, - variables: { - slug: project1.slug, - connectedWalletUserId: user!.id, - }, - }); - - const project = result.data.data.projectBySlug; - assert.equal(Number(project.id), project1.id); - assert.isNotOk(project.projectVerificationForm); - assert.equal(project.verificationFormStatus, verificationForm.status); - }); it('should return projects with indicated slug', async () => { const walletAddress = generateRandomEtheriumAddress(); - const project1 = await saveProjectDirectlyToDb({ - ...createProjectData(), - title: String(new Date().getTime()), - slug: String(new Date().getTime()), - walletAddress, - }); - + const accessToken = await generateTestAccessToken(SEED_DATA.FIRST_USER.id); + const sampleProject1 = { + title: walletAddress, + adminUserId: SEED_DATA.FIRST_USER.id, + addresses: [ + { + address: walletAddress, + networkId: NETWORK_IDS.XDAI, + chainType: ChainType.EVM, + }, + ], + }; + const res1 = await axios.post( + graphqlUrl, + { + query: createProjectQuery, + variables: { + project: sampleProject1, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + const _project = res1.data.data.createProject; const result = await axios.post(graphqlUrl, { query: fetchProjectBySlugQuery, variables: { - slug: project1.slug, + slug: _project.slug, }, }); - const project = result.data.data.projectBySlug; - assert.equal(Number(project.id), project1.id); + assert.equal(Number(project.id), Number(_project.id)); assert.isOk(project.adminUser.walletAddress); assert.isOk(project.givbackFactor); - assert.isNull(project.projectVerificationForm); assert.isOk(project.adminUser.firstName); assert.isNotOk(project.adminUser.email); assert.isNotEmpty(project.addresses); diff --git a/src/resolvers/projectResolver.ts b/src/resolvers/projectResolver.ts index fe4ff09c7..0cb10693b 100644 --- a/src/resolvers/projectResolver.ts +++ b/src/resolvers/projectResolver.ts @@ -1,5 +1,5 @@ import { Max, Min } from 'class-validator'; -import { Brackets, Repository } from 'typeorm'; +import { Brackets, getMetadataArgsStorage, Repository } from 'typeorm'; import { Service } from 'typedi'; import { Arg, @@ -7,6 +7,7 @@ import { ArgsType, Ctx, Field, + Info, InputType, Int, Mutation, @@ -15,6 +16,7 @@ import { registerEnumType, Resolver, } from 'type-graphql'; +import graphqlFields from 'graphql-fields'; import { SelectQueryBuilder } from 'typeorm/query-builder/SelectQueryBuilder'; import { ObjectLiteral } from 'typeorm/common/ObjectLiteral'; import { Reaction } from '../entities/reaction'; @@ -77,7 +79,7 @@ import { FilterProjectQueryInputParams, filterProjectsQuery, findProjectById, - findProjectBySlugWithoutAnyJoin, + findProjectIdBySlug, totalProjectsPerDate, totalProjectsPerDateByMonthAndYear, userIsOwnerOfProject, @@ -85,7 +87,7 @@ import { import { sortTokensByOrderAndAlphabets } from '../utils/tokenUtils'; import { getNotificationAdapter } from '../adapters/adaptersFactory'; import { NETWORK_IDS } from '../provider'; -import { getVerificationFormByProjectId } from '../repositories/projectVerificationRepository'; +import { getVerificationFormStatusByProjectId } from '../repositories/projectVerificationRepository'; import { resourcePerDateReportValidator, validateWithJoiSchema, @@ -849,51 +851,98 @@ export class ProjectResolver { return project; } + // Helper method to get the fields of the Project entity + private getEntityFields(entity: typeof Project): string[] { + const metadata = getMetadataArgsStorage(); + const columns = metadata.columns.filter(col => col.target === entity); + return columns.map(col => col.propertyName); + } + @Query(_returns => ProjectBySlugResponse) async projectBySlug( @Arg('slug') slug: string, @Arg('connectedWalletUserId', _type => Int, { nullable: true }) connectedWalletUserId: number, @Ctx() { req: { user } }: ApolloContext, + @Info() info: any, ) { - const viewerUserId = connectedWalletUserId || user?.userId; - let isOwnerOfProject = false; - - // ensure it's the owner - if (viewerUserId) { - isOwnerOfProject = await userIsOwnerOfProject(viewerUserId, slug); - } - - const minimalProject = await findProjectBySlugWithoutAnyJoin(slug); + const minimalProject = await findProjectIdBySlug(slug); if (!minimalProject) { throw new Error(i18n.__(translationErrorMessagesKeys.PROJECT_NOT_FOUND)); } - const campaignSlugs = (await getAllProjectsRelatedToActiveCampaigns())[ - minimalProject.id - ]; + + // Extract requested fields + const fields = graphqlFields(info); + const projectFields = this.getEntityFields(Project); + + // Filter requested fields to only include those in the Project entity + const selectedFields = Object.keys(fields).filter(field => + projectFields.includes(field), + ); + + // Dynamically build the select fields + const selectFields = selectedFields.map(field => `project.${field}`); let query = this.projectRepository .createQueryBuilder('project') + .select(selectFields) .where(`project.id = :id`, { id: minimalProject.id, }) - .leftJoinAndSelect('project.status', 'status') - .leftJoinAndSelect( - 'project.categories', - 'categories', - 'categories.isActive = :isActive', - { isActive: true }, - ) - .leftJoinAndSelect('categories.mainCategory', 'mainCategory') - .leftJoinAndSelect('project.organization', 'organization') - .leftJoinAndSelect('project.addresses', 'addresses') - .leftJoinAndSelect('project.socialMedia', 'socialMedia') - .leftJoinAndSelect('project.anchorContracts', 'anchor_contract_address') - .leftJoinAndSelect('project.projectPower', 'projectPower') - .leftJoinAndSelect('project.projectInstantPower', 'projectInstantPower') - .leftJoinAndSelect('project.qfRounds', 'qfRounds') - .leftJoinAndSelect('project.projectFuturePower', 'projectFuturePower') - .leftJoinAndMapMany( + .leftJoinAndSelect('project.status', 'status'); + + if (fields.categories) { + query = query + .leftJoinAndSelect( + 'project.categories', + 'categories', + 'categories.isActive = :isActive', + { isActive: true }, + ) + .leftJoinAndSelect('categories.mainCategory', 'mainCategory') + .orderBy({ + 'mainCategory.title': 'ASC', + 'categories.name': 'ASC', + }); + } + if (fields.organization) { + query = query.leftJoinAndSelect('project.organization', 'organization'); + } + if (fields.addresses) { + query = query.leftJoinAndSelect('project.addresses', 'addresses'); + } + if (fields.socialMedia) { + query = query.leftJoinAndSelect('project.socialMedia', 'socialMedia'); + } + if (fields.anchorContracts) { + query = query.leftJoinAndSelect( + 'project.anchorContracts', + 'anchor_contract_address', + ); + } + if (fields.projectPower) { + query = query.leftJoinAndSelect('project.projectPower', 'projectPower'); + } + if (fields.projectInstantPower) { + query = query.leftJoinAndSelect( + 'project.projectInstantPower', + 'projectInstantPower', + ); + } + if (fields.qfRounds) { + query = query.leftJoinAndSelect('project.qfRounds', 'qfRounds'); + } + if (fields.projectFuturePower) { + query = query.leftJoinAndSelect( + 'project.projectFuturePower', + 'projectFuturePower', + ); + } + if (fields.campaigns) { + const campaignSlugs = (await getAllProjectsRelatedToActiveCampaigns())[ + minimalProject.id + ]; + query = query.leftJoinAndMapMany( 'project.campaigns', Campaign, 'campaigns', @@ -902,38 +951,49 @@ export class ProjectResolver { slug, campaignSlugs, }, - ) - .leftJoin('project.adminUser', 'user') - .addSelect(publicSelectionFields); // aliased selection - - query = ProjectResolver.addUserReaction(query, connectedWalletUserId, user); - - if (isOwnerOfProject) { - query = ProjectResolver.addProjectVerificationForm( + ); + } + if (fields.adminUser) { + const adminUserFields = Object.keys(fields.adminUser).map( + field => `user.${field}`, + ); + const filterByPublicFields = publicSelectionFields.filter(field => + adminUserFields.includes(field), + ); + query = query + .leftJoin('project.adminUser', 'user') + .addSelect(filterByPublicFields); // aliased selection + } + if (fields.reaction) { + query = ProjectResolver.addUserReaction( query, connectedWalletUserId, user, ); } - query = query.orderBy({ - 'mainCategory.title': 'ASC', - 'categories.name': 'ASC', - }); - const project = await query.getOne(); canUserVisitProject(project, user?.userId); - const verificationForm = - project?.projectVerificationForm || - (await getVerificationFormByProjectId(project?.id as number)); - if (verificationForm) { - (project as Project).verificationFormStatus = verificationForm?.status; - } + if (fields.verificationFormStatus) { + const viewerUserId = connectedWalletUserId || user?.userId; + const isOwnerOfProject = await userIsOwnerOfProject(viewerUserId, slug); + if (isOwnerOfProject) { + const verificationForm = await getVerificationFormStatusByProjectId( + project?.id as number, + ); + if (verificationForm) { + (project as Project).verificationFormStatus = + verificationForm?.status; + } + } + } + if (fields.givbackFactor) { + const { givbackFactor } = await calculateGivbackFactor(project!.id); + return { ...project, givbackFactor }; + } // We know that we have the project because if we reach this line means minimalProject is not null - const { givbackFactor } = await calculateGivbackFactor(project!.id); - - return { ...project, givbackFactor }; + return project; } @Mutation(_returns => Project) diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index 89666f380..094a908d3 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -1087,53 +1087,6 @@ export const fetchProjectBySlugQuery = ` name isActive } - projectVerificationForm { - status - id - isTermAndConditionsAccepted - emailConfirmationTokenExpiredAt - email - emailConfirmationToken - emailConfirmationSent - emailConfirmationSentAt - emailConfirmedAt - emailConfirmed - projectRegistry { - organizationDescription - isNonProfitOrganization - organizationCountry - organizationWebsite - attachments - organizationName - } - personalInfo { - email - walletAddress - fullName - } - projectContacts { - name - url - } - milestones { - mission - foundationDate - achievedMilestones - achievedMilestonesProofs - problem - plans - impact - } - managingFunds { - description - relatedAddresses { - address - networkId - chainType - title - } - } - } status { id symbol @@ -1167,6 +1120,7 @@ export const fetchProjectBySlugQuery = ` email firstName walletAddress + email } totalReactions totalDonations