From db91549d3e67a3b1ecc924499654021e7b2f73ad Mon Sep 17 00:00:00 2001 From: Mohammad Ranjbar Z Date: Wed, 27 Sep 2023 12:30:24 +0330 Subject: [PATCH] Add Other types of campaigns to projectBySlug webservice https://github.com/Giveth/impact-graph/issues/1051#issuecomment-1733713799 --- config/example.env | 3 + config/test.env | 2 + src/entities/campaign.ts | 17 +++ src/repositories/projectRepository.test.ts | 47 +++++-- src/repositories/projectRepository.ts | 11 ++ src/resolvers/projectResolver.test.ts | 136 ++++++++++++++++++++- src/resolvers/projectResolver.ts | 25 ++-- src/services/campaignService.ts | 59 +++++++-- 8 files changed, 272 insertions(+), 28 deletions(-) diff --git a/config/example.env b/config/example.env index 86aa13b79..9ecd2a5f9 100644 --- a/config/example.env +++ b/config/example.env @@ -202,3 +202,6 @@ POWER_BALANCE_AGGREGATOR_ADAPTER=powerBalanceAggregator NUMBER_OF_BALANCE_AGGREGATOR_BATCH=20 QF_ROUND_ESTIMATED_MATCHING_CACHE_DURATION=60000 + +# OPTIONAL - default: Every 10 minutes +PROJECT_CAMPAIGNS_CACHE_DURATION=600000 diff --git a/config/test.env b/config/test.env index b9bb1c11c..a5786e08c 100644 --- a/config/test.env +++ b/config/test.env @@ -175,3 +175,5 @@ NUMBER_OF_BALANCE_AGGREGATOR_BATCH=7 # ! millisecond cache, if we increase cache in test ENV we might get some errors in tests QF_ROUND_ESTIMATED_MATCHING_CACHE_DURATION=1 +# ! millisecond cache, if we increase cache in test ENV we might get some errors in tests +PROJECT_CAMPAIGNS_CACHE_DURATION=1 diff --git a/src/entities/campaign.ts b/src/entities/campaign.ts index fcd3d8c21..7e1860e10 100644 --- a/src/entities/campaign.ts +++ b/src/entities/campaign.ts @@ -35,9 +35,26 @@ export enum CampaignFilterField { } export enum CampaignType { + // https://github.com/Giveth/impact-graph/blob/staging/docs/campaignsInstruction.md + + // In these type of projects we pick some projects to show them in campaign, + // for instance for Turkey earthquake we pick some projects. + // so we just need to add slug of those projects in Related Projects Slugs and in + // what order we add them they will be shown in frontend ManuallySelected = 'ManuallySelected', + + // Sometimes in a campaign we just want to show projects in an specified order, + // for instance we can create a campaign like ** Check projects that received most likes** so for + // this campaign you set SortField as campaign type and then you can use one of below sorting fields SortField = 'SortField', + + // Sometimes we need to filter some projects in a campaign, + // for instance Let's verified projects that accept funds on Gnosis chain, + // for this we can Add verified and acceptFundOnGnosis filters FilterFields = 'FilterFields', + + // Some campaigns don't include any project in them and they are just some banner + // like Feeling $nice? campaign in below image WithoutProjects = 'WithoutProjects', } diff --git a/src/repositories/projectRepository.test.ts b/src/repositories/projectRepository.test.ts index 50cce6ef4..b4a20ce89 100644 --- a/src/repositories/projectRepository.test.ts +++ b/src/repositories/projectRepository.test.ts @@ -1,6 +1,7 @@ import { findProjectById, findProjectBySlug, + findProjectBySlugWithoutAnyJoin, findProjectByWalletAddress, findProjectsByIdArray, findProjectsBySlugArray, @@ -56,6 +57,20 @@ describe( updateDescriptionSummaryTestCases, ); +describe('verifyProject test cases', verifyProjectTestCases); +describe('verifyMultipleProjects test cases', verifyMultipleProjectsTestCases); +describe('findProjectById test cases', findProjectByIdTestCases); +describe('findProjectsByIdArray test cases', findProjectsByIdArrayTestCases); +describe('findProjectBySlug test cases', findProjectBySlugTestCases); +describe( + 'findProjectBySlugWithoutAnyJoin test cases', + findProjectBySlugWithoutAnyJoinTestCases, +); +describe( + 'findProjectsBySlugArray test cases', + findProjectsBySlugArrayTestCases, +); + function projectsWithoutUpdateAfterTimeFrameTestCases() { it('should return projects created a long time ago', async () => { const superExpiredProject = await saveProjectDirectlyToDb({ @@ -96,22 +111,13 @@ function projectsWithoutUpdateAfterTimeFrameTestCases() { }); } -describe('verifyProject test cases', verifyProjectTestCases); -describe('verifyMultipleProjects test cases', verifyMultipleProjectsTestCases); -describe('findProjectById test cases', findProjectByIdTestCases); -describe('findProjectsByIdArray test cases', findProjectsByIdArrayTestCases); -describe('findProjectBySlug test cases', findProjectBySlugTestCases); -describe( - 'findProjectsBySlugArray test cases', - findProjectsBySlugArrayTestCases, -); - function findProjectBySlugTestCases() { - it('Should find project by id', async () => { + it('Should find project by slug', async () => { const project = await saveProjectDirectlyToDb(createProjectData()); const foundProject = await findProjectBySlug(project.slug as string); assert.isOk(foundProject); assert.equal(foundProject?.id, project.id); + assert.isOk(foundProject?.adminUser); }); it('should not find project when project doesnt exists', async () => { @@ -120,6 +126,25 @@ function findProjectBySlugTestCases() { }); } +function findProjectBySlugWithoutAnyJoinTestCases() { + it('Should find project by slug', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + const foundProject = await findProjectBySlugWithoutAnyJoin( + project.slug as string, + ); + assert.isOk(foundProject); + assert.equal(foundProject?.id, project.id); + assert.isNotOk(foundProject?.adminUser); + }); + + it('should not find project when project doesnt exists', async () => { + const foundProject = await findProjectBySlugWithoutAnyJoin( + new Date().toString(), + ); + assert.isNull(foundProject); + }); +} + function findProjectsBySlugArrayTestCases() { it('Should find project multi projects by slug', async () => { const project1 = await saveProjectDirectlyToDb(createProjectData()); diff --git a/src/repositories/projectRepository.ts b/src/repositories/projectRepository.ts index 72f7003b4..25e762dce 100644 --- a/src/repositories/projectRepository.ts +++ b/src/repositories/projectRepository.ts @@ -211,6 +211,17 @@ export const findProjectBySlug = (slug: string): Promise => { ); }; +export const findProjectBySlugWithoutAnyJoin = ( + slug: string, +): Promise => { + // check current slug and previous slugs + return Project.createQueryBuilder('project') + .where(`:slug = ANY(project."slugHistory") or project.slug = :slug`, { + slug, + }) + .getOne(); +}; + export const verifyMultipleProjects = async (params: { verified: boolean; projectsIds: string[] | number[]; diff --git a/src/resolvers/projectResolver.test.ts b/src/resolvers/projectResolver.test.ts index 0aaae662f..882c4edad 100644 --- a/src/resolvers/projectResolver.test.ts +++ b/src/resolvers/projectResolver.test.ts @@ -90,7 +90,12 @@ import { refreshUserProjectPowerView } from '../repositories/userProjectPowerVie import { AppDataSource } from '../orm'; // We are using cache so redis needs to be cleared for tests with same filters import { redis } from '../redis'; -import { Campaign, CampaignType } from '../entities/campaign'; +import { + Campaign, + CampaignFilterField, + CampaignSortingField, + CampaignType, +} from '../entities/campaign'; import { generateRandomString, getHtmlTextSummary } from '../utils/utils'; import { FeaturedUpdate } from '../entities/featuredUpdate'; import { @@ -5456,7 +5461,7 @@ function projectBySlugTestCases() { assert.isTrue(project.projectPower.totalPower > 0); }); - it('should return projects including active campaigns', async () => { + it('should return projects including active ManuallySelected campaigns', async () => { const projectWithCampaign = await saveProjectDirectlyToDb({ ...createProjectData(), title: String(new Date().getTime()), @@ -5505,6 +5510,133 @@ function projectBySlugTestCases() { await campaign.remove(); }); + it('should return projects including active SortField campaigns', async () => { + const projectWithCampaign = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + const campaign = await Campaign.create({ + isActive: true, + type: CampaignType.SortField, + sortingField: CampaignSortingField.Newest, + slug: generateRandomString(), + title: 'title1', + description: 'description1', + photo: 'https://google.com', + order: 1, + }).save(); + const result = await axios.post(graphqlUrl, { + query: fetchProjectsBySlugQuery, + variables: { + slug: projectWithCampaign.slug, + }, + }); + + const project = result.data.data.projectBySlug; + assert.equal(Number(project.id), projectWithCampaign.id); + + assert.exists(project.campaigns); + assert.isNotEmpty(project.campaigns); + assert.equal(project.campaigns[0].id, campaign.id); + + const projectWithoutCampaignResult = await axios.post(graphqlUrl, { + query: fetchProjectsBySlugQuery, + variables: { + // and old project that I'm sure it would not be in the Newest campaign + slug: SEED_DATA.FIRST_PROJECT.slug, + }, + }); + + const project2 = projectWithoutCampaignResult.data.data.projectBySlug; + assert.equal(Number(project2.id), SEED_DATA.FIRST_PROJECT.id); + + assert.isEmpty(project2.campaigns); + + await campaign.remove(); + }); + + it('should return projects including active FilterField campaigns (acceptOnGnosis)', async () => { + // In this filter the default sorting for projects is givPower so I need to create a project with power + // to be sure that it will be in the campaign + await PowerBoosting.clear(); + await InstantPowerBalance.clear(); + + const user1 = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + + const projectWithCampaign = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + networkId: NETWORK_IDS.XDAI, + }); + + const projectWithoutCampaign = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + networkId: NETWORK_IDS.POLYGON, + }); + + await Promise.all( + [[user1, projectWithCampaign, 10]].map(item => { + const [user, project, percentage] = item as [User, Project, number]; + return insertSinglePowerBoosting({ + user, + project, + percentage, + }); + }), + ); + + await saveOrUpdateInstantPowerBalances([ + { + userId: user1.id, + balance: 10000, + balanceAggregatorUpdatedAt: new Date(1_000_000), + }, + ]); + + await updateInstantBoosting(); + + const campaign = await Campaign.create({ + isActive: true, + type: CampaignType.FilterFields, + filterFields: [CampaignFilterField.acceptFundOnGnosis], + slug: generateRandomString(), + title: 'title1', + description: 'description1', + photo: 'https://google.com', + order: 1, + }).save(); + const result = await axios.post(graphqlUrl, { + query: fetchProjectsBySlugQuery, + variables: { + slug: projectWithCampaign.slug, + }, + }); + + const fetchedProject = result.data.data.projectBySlug; + assert.equal(Number(fetchedProject.id), projectWithCampaign.id); + + assert.exists(fetchedProject.campaigns); + assert.isNotEmpty(fetchedProject.campaigns); + assert.equal(fetchedProject.campaigns[0].id, campaign.id); + + const projectWithoutCampaignResult = await axios.post(graphqlUrl, { + query: fetchProjectsBySlugQuery, + variables: { + slug: projectWithoutCampaign.slug, + }, + }); + + const project2 = projectWithoutCampaignResult.data.data.projectBySlug; + assert.equal(Number(project2.id), projectWithoutCampaign.id); + + assert.isEmpty(project2.campaigns); + + await campaign.remove(); + }); it('should return projects including active campaigns, even when sent slug is in the slugHistory of project', async () => { const projectWithCampaign = await saveProjectDirectlyToDb({ diff --git a/src/resolvers/projectResolver.ts b/src/resolvers/projectResolver.ts index 2ec7ab8b2..6e246deff 100644 --- a/src/resolvers/projectResolver.ts +++ b/src/resolvers/projectResolver.ts @@ -77,6 +77,7 @@ import { FilterProjectQueryInputParams, filterProjectsQuery, findProjectById, + findProjectBySlugWithoutAnyJoin, totalProjectsPerDate, totalProjectsPerDateByMonthAndYear, userIsOwnerOfProject, @@ -108,6 +109,7 @@ import { FeaturedUpdate } from '../entities/featuredUpdate'; import { PROJECT_UPDATE_CONTENT_MAX_LENGTH } from '../constants/validators'; import { calculateGivbackFactor } from '../services/givbackService'; import { ProjectBySlugResponse } from './types/projectResolver'; +import { getAllProjectsRelatedToActiveCampaigns } from '../services/campaignService'; @ObjectType() class AllProjects { @@ -898,11 +900,18 @@ export class ProjectResolver { isOwnerOfProject = await userIsOwnerOfProject(viewerUserId, slug); } + const minimalProject = await findProjectBySlugWithoutAnyJoin(slug); + if (!minimalProject) { + throw new Error(i18n.__(translationErrorMessagesKeys.PROJECT_NOT_FOUND)); + } + const campaignSlugs = (await getAllProjectsRelatedToActiveCampaigns())[ + minimalProject.id + ]; + let query = this.projectRepository .createQueryBuilder('project') - // check current slug and previous slugs - .where(`:slug = ANY(project."slugHistory") or project.slug = :slug`, { - slug, + .where(`project.id = :id`, { + id: minimalProject.id, }) .leftJoinAndSelect('project.status', 'status') .leftJoinAndSelect( @@ -922,9 +931,10 @@ export class ProjectResolver { 'project.campaigns', Campaign, 'campaigns', - '(campaigns."relatedProjectsSlugs" && ARRAY[:slug]::text[] OR campaigns."relatedProjectsSlugs" && project."slugHistory") AND campaigns."isActive" = TRUE', + '((campaigns."relatedProjectsSlugs" && ARRAY[:slug]::text[] OR campaigns."relatedProjectsSlugs" && project."slugHistory") AND campaigns."isActive" = TRUE) OR (campaigns.slug = ANY(:campaignSlugs))', { slug, + campaignSlugs, }, ) .leftJoin('project.adminUser', 'user') @@ -946,9 +956,6 @@ export class ProjectResolver { }); const project = await query.getOne(); - if (!project) { - throw new Error(i18n.__(translationErrorMessagesKeys.PROJECT_NOT_FOUND)); - } canUserVisitProject(project, String(user?.userId)); const verificationForm = project?.projectVerificationForm || @@ -956,7 +963,9 @@ export class ProjectResolver { if (verificationForm) { (project as Project).verificationFormStatus = verificationForm?.status; } - const { givbackFactor } = await calculateGivbackFactor(project.id); + + // 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 }; } diff --git a/src/services/campaignService.ts b/src/services/campaignService.ts index 1851d13af..ede5e7265 100644 --- a/src/services/campaignService.ts +++ b/src/services/campaignService.ts @@ -7,16 +7,15 @@ import { FilterField, Project, SortingField } from '../entities/project'; import { findUserReactionsByProjectIds } from '../repositories/reactionRepository'; import { ModuleThread, Pool } from 'threads'; import { ProjectResolverWorker } from '../workers/projectsResolverWorker'; +import { QueryBuilder } from 'typeorm/query-builder/QueryBuilder'; +import { findAllActiveCampaigns } from '../repositories/campaignRepository'; const projectFiltersCacheDuration = Number(process.env.PROJECT_FILTERS_THREADS_POOL_DURATION) || 60000; -export const fillCampaignProjects = async (params: { - userId?: number; - campaign: Campaign; - projectsFiltersThreadPool: Pool>; -}): Promise => { - const { campaign, userId, projectsFiltersThreadPool } = params; +const createFetchCampaignProjectsQuery = ( + campaign: Campaign, +): FilterProjectQueryInputParams | null => { const limit = 10; const skip = 0; const projectsQueryParams: FilterProjectQueryInputParams = { @@ -36,9 +35,55 @@ export const fillCampaignProjects = async (params: { campaign.sortingField as unknown as SortingField; } else if (campaign.type === CampaignType.WithoutProjects) { // Dont add projects to this campaign type - return campaign; + return null; + } + + return projectsQueryParams; +}; +let projectCampaignCache: { [key: number]: string[] } | undefined; + +export const getAllProjectsRelatedToActiveCampaigns = async (): Promise<{ + [key: number]: string[]; +}> => { + // It returns all project and campaigns( excluding manuallySelectedCampaign) + if (projectCampaignCache) { + return projectCampaignCache; } + projectCampaignCache = {}; + const activeCampaigns = await findAllActiveCampaigns(); + for (const campaign of activeCampaigns) { + const projectsQueryParams = createFetchCampaignProjectsQuery(campaign); + if (!projectsQueryParams) { + break; + } + const projectsQuery = filterProjectsQuery(projectsQueryParams); + const projects = await projectsQuery.getMany(); + for (const project of projects) { + projectCampaignCache[project.id] + ? projectCampaignCache[project.id].push(campaign.slug) + : (projectCampaignCache[project.id] = [campaign.slug]); + } + } + const projectCampaignsCacheDuration = + Number(process.env.PROJECT_CAMPAIGNS_CACHE_DURATION) || 10 * 60 * 1000; + setTimeout(() => { + // We make it undefined every 10 minutes, to refresh it + projectCampaignCache = undefined; + }, projectCampaignsCacheDuration); + + return projectCampaignCache; +}; +export const fillCampaignProjects = async (params: { + userId?: number; + campaign: Campaign; + projectsFiltersThreadPool: Pool>; +}): Promise => { + const { campaign, userId, projectsFiltersThreadPool } = params; + const projectsQueryParams = createFetchCampaignProjectsQuery(campaign); + if (!projectsQueryParams) { + return campaign; + } const projectsQuery = filterProjectsQuery(projectsQueryParams); const projectsQueryCacheKey = await projectsFiltersThreadPool.queue(hasher => hasher.hashProjectFilters({