From ad621f51fb61e03fba5ccd721e0206af54e28010 Mon Sep 17 00:00:00 2001 From: ali Date: Tue, 12 Aug 2025 01:55:53 +0330 Subject: [PATCH 1/7] add rank field to project entity and update sorting functionality in project repository --- migration/1746613421847-addRankToProject.ts | 15 +++++ .../1746613421848-populateProjectRanks.ts | 61 +++++++++++++++++++ src/entities/project.ts | 6 ++ src/repositories/projectRepository.ts | 3 + 4 files changed, 85 insertions(+) create mode 100644 migration/1746613421847-addRankToProject.ts create mode 100644 migration/1746613421848-populateProjectRanks.ts diff --git a/migration/1746613421847-addRankToProject.ts b/migration/1746613421847-addRankToProject.ts new file mode 100644 index 000000000..f77478ae5 --- /dev/null +++ b/migration/1746613421847-addRankToProject.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRankToProject1746613421847 implements MigrationInterface { + name = 'AddRankToProject1746613421847'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "project" ADD "rank" real DEFAULT '0'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "project" DROP COLUMN "rank"`); + } +} diff --git a/migration/1746613421848-populateProjectRanks.ts b/migration/1746613421848-populateProjectRanks.ts new file mode 100644 index 000000000..08d3d494b --- /dev/null +++ b/migration/1746613421848-populateProjectRanks.ts @@ -0,0 +1,61 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PopulateProjectRanks1746613421848 implements MigrationInterface { + name = 'PopulateProjectRanks1746613421848'; + + public async up(queryRunner: QueryRunner): Promise { + // Define the project ticker to rank mapping + const projectRanks = [ + { ticker: 'PACK', rank: 1 }, + { ticker: 'X23', rank: 1 }, + { ticker: 'TDM', rank: 1 }, + { ticker: 'PRSM', rank: 1 }, + { ticker: 'CTZN', rank: 1 }, + { ticker: 'H2DAO', rank: 2 }, + { ticker: 'LOCK', rank: 2 }, + { ticker: 'ACHAD', rank: 2 }, + { ticker: 'GRNDT', rank: 2 }, + { ticker: 'AKA', rank: 3 }, + { ticker: 'BEAST', rank: 3 }, + { ticker: 'MELS', rank: 3 }, + ]; + + // Update each project's rank based on ticker + for (const { ticker, rank } of projectRanks) { + await queryRunner.query( + `UPDATE "project" + SET "rank" = $1 + WHERE "abc"->>'tokenTicker' = $2`, + [rank, ticker], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + // Define the project tickers that were updated + const projectTickers = [ + 'PACK', + 'X23', + 'TDM', + 'PRSM', + 'CTZN', + 'H2DAO', + 'LOCK', + 'ACHAD', + 'GRNDT', + 'AKA', + 'BEAST', + 'MELS', + ]; + + // Reset rank to default (0) for these projects + for (const ticker of projectTickers) { + await queryRunner.query( + `UPDATE "project" + SET "rank" = 0 + WHERE "abc"->>'tokenTicker' = $1`, + [ticker], + ); + } + } +} diff --git a/src/entities/project.ts b/src/entities/project.ts index 588719fff..22a6dfd8d 100644 --- a/src/entities/project.ts +++ b/src/entities/project.ts @@ -71,6 +71,7 @@ export enum SortingField { QualityScore = 'QualityScore', ActiveQfRoundRaisedFunds = 'ActiveQfRoundRaisedFunds', EstimatedMatching = 'EstimatedMatching', + Rank = 'Rank', } export enum FilterField { @@ -104,6 +105,7 @@ export enum OrderField { Donations = 'totalDonations', TraceDonations = 'totalTraceDonations', AcceptGiv = 'givingBlocksId', + Rank = 'rank', } export enum RevokeSteps { @@ -310,6 +312,10 @@ export class Project extends BaseEntity { @Column('float', { nullable: true }) balance: number = 0; + @Field(_type => Float, { nullable: true }) + @Column('real', { nullable: true, default: 0 }) + rank?: number; + @Field({ nullable: true }) @Column({ nullable: true }) stripeAccountId?: string; diff --git a/src/repositories/projectRepository.ts b/src/repositories/projectRepository.ts index 93df7d253..566b88593 100644 --- a/src/repositories/projectRepository.ts +++ b/src/repositories/projectRepository.ts @@ -189,6 +189,9 @@ export const filterProjectsQuery = (params: FilterProjectQueryInputParams) => { .addOrderBy(`project.verified`, OrderDirection.DESC); } break; + case SortingField.Rank: + query.orderBy('project.rank', OrderDirection.ASC, 'NULLS LAST'); + break; default: query.orderBy('project.creationDate', OrderDirection.DESC); break; From 393810167405c1aefea9a500bdaa0e6de881a478 Mon Sep 17 00:00:00 2001 From: ali Date: Tue, 12 Aug 2025 02:07:31 +0330 Subject: [PATCH 2/7] add YOUTUBE_REEL to ProjectSocialMediaType enum --- ...-addYoutubeReelToProjectSocialMediaType.ts | 21 ++++ ...46613421850-populateProjectYoutubeReels.ts | 104 ++++++++++++++++++ src/types/projectSocialMediaType.ts | 1 + 3 files changed, 126 insertions(+) create mode 100644 migration/1746613421849-addYoutubeReelToProjectSocialMediaType.ts create mode 100644 migration/1746613421850-populateProjectYoutubeReels.ts diff --git a/migration/1746613421849-addYoutubeReelToProjectSocialMediaType.ts b/migration/1746613421849-addYoutubeReelToProjectSocialMediaType.ts new file mode 100644 index 000000000..4ab153a34 --- /dev/null +++ b/migration/1746613421849-addYoutubeReelToProjectSocialMediaType.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddYoutubeReelToProjectSocialMediaType1746613421849 + implements MigrationInterface +{ + name = 'AddYoutubeReelToProjectSocialMediaType1746613421849'; + + public async up(queryRunner: QueryRunner): Promise { + // Add YOUTUBE_REEL to the project_social_media_type_enum + await queryRunner.query( + `ALTER TYPE "public"."project_social_media_type_enum" ADD VALUE 'YOUTUBE_REEL'`, + ); + } + + public async down(_queryRunner: QueryRunner): Promise { + // Note: PostgreSQL doesn't support removing enum values directly + // This would require recreating the enum type and updating all references + // For safety, we'll leave the enum value in place during rollback + // If rollback is absolutely necessary, it would need to be done manually + } +} diff --git a/migration/1746613421850-populateProjectYoutubeReels.ts b/migration/1746613421850-populateProjectYoutubeReels.ts new file mode 100644 index 000000000..a09659c25 --- /dev/null +++ b/migration/1746613421850-populateProjectYoutubeReels.ts @@ -0,0 +1,104 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PopulateProjectYoutubeReels1746613421850 + implements MigrationInterface +{ + name = 'PopulateProjectYoutubeReels1746613421850'; + + public async up(queryRunner: QueryRunner): Promise { + // Define the project ticker to YouTube Reel URL mapping + const projectYoutubeReels = [ + { + ticker: 'PACK', + url: 'https://youtube.com/shorts/8Gk-Ly8Foac?feature=share', + }, + { + ticker: 'X23', + url: 'https://youtube.com/shorts/AnKtMfnQrmU?feature=share', + }, + { + ticker: 'TDM', + url: 'https://youtube.com/shorts/ZZX6NuXkJO8?feature=share', + }, + { + ticker: 'PRSM', + url: 'https://youtube.com/shorts/k5qXJH-o2Z0?feature=share', + }, + { + ticker: 'CTZN', + url: 'https://youtube.com/shorts/neF1zbCeImU?feature=share', + }, + { + ticker: 'H2DAO', + url: 'https://youtube.com/shorts/Zgd30u7ta-A?feature=share', + }, + { + ticker: 'LOCK', + url: 'https://youtube.com/shorts/WLeG91LzzVc?feature=share', + }, + { + ticker: 'ACHAD', + url: 'https://youtube.com/shorts/G0-PXR7V-ro?feature=share', + }, + { + ticker: 'BEAST', + url: 'https://youtube.com/shorts/Ouq2984E5F4?feature=share', + }, + { + ticker: 'MELS', + url: 'https://youtube.com/shorts/KTXsNhANaDs?feature=share', + }, + ]; + + // Insert YouTube Reel social media entries for each project + for (const { ticker, url } of projectYoutubeReels) { + // First, get the project ID and admin user ID for the project with this ticker + const projectResult = await queryRunner.query( + `SELECT id, "adminUserId" + FROM "project" + WHERE "abc"->>'tokenTicker' = $1`, + [ticker], + ); + + if (projectResult.length > 0) { + const projectId = projectResult[0].id; + const adminUserId = projectResult[0].adminUserId; + + // Insert the YouTube Reel social media entry + await queryRunner.query( + `INSERT INTO "project_social_media" ("type", "link", "projectId", "userId") + VALUES ('YOUTUBE_REEL', $1, $2, $3)`, + [url, projectId, adminUserId], + ); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + // Define the project tickers that were updated + const projectTickers = [ + 'PACK', + 'X23', + 'TDM', + 'PRSM', + 'CTZN', + 'H2DAO', + 'LOCK', + 'ACHAD', + 'BEAST', + 'MELS', + ]; + + // Remove YouTube Reel social media entries for these projects + for (const ticker of projectTickers) { + await queryRunner.query( + `DELETE FROM "project_social_media" + WHERE "type" = 'YOUTUBE_REEL' + AND "projectId" IN ( + SELECT id FROM "project" WHERE "abc"->>'tokenTicker' = $1 + )`, + [ticker], + ); + } + } +} diff --git a/src/types/projectSocialMediaType.ts b/src/types/projectSocialMediaType.ts index 89bd7a987..9991defeb 100644 --- a/src/types/projectSocialMediaType.ts +++ b/src/types/projectSocialMediaType.ts @@ -5,6 +5,7 @@ export enum ProjectSocialMediaType { X = 'X', INSTAGRAM = 'INSTAGRAM', YOUTUBE = 'YOUTUBE', + YOUTUBE_REEL = 'YOUTUBE_REEL', LINKEDIN = 'LINKEDIN', REDDIT = 'REDDIT', DISCORD = 'DISCORD', From 0cc08057b0d5517ebe35f7d0c4b738464d5a3dbf Mon Sep 17 00:00:00 2001 From: ali Date: Tue, 12 Aug 2025 02:14:11 +0330 Subject: [PATCH 3/7] rename youtube reel to reel video --- ...9-addReelVideoToProjectSocialMediaType.ts} | 8 ++++---- ...746613421850-populateProjectReelVideos.ts} | 20 +++++++++---------- src/types/projectSocialMediaType.ts | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) rename migration/{1746613421849-addYoutubeReelToProjectSocialMediaType.ts => 1746613421849-addReelVideoToProjectSocialMediaType.ts} (74%) rename migration/{1746613421850-populateProjectYoutubeReels.ts => 1746613421850-populateProjectReelVideos.ts} (82%) diff --git a/migration/1746613421849-addYoutubeReelToProjectSocialMediaType.ts b/migration/1746613421849-addReelVideoToProjectSocialMediaType.ts similarity index 74% rename from migration/1746613421849-addYoutubeReelToProjectSocialMediaType.ts rename to migration/1746613421849-addReelVideoToProjectSocialMediaType.ts index 4ab153a34..a284bd2c0 100644 --- a/migration/1746613421849-addYoutubeReelToProjectSocialMediaType.ts +++ b/migration/1746613421849-addReelVideoToProjectSocialMediaType.ts @@ -1,14 +1,14 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddYoutubeReelToProjectSocialMediaType1746613421849 +export class AddReelVideoToProjectSocialMediaType1746613421849 implements MigrationInterface { - name = 'AddYoutubeReelToProjectSocialMediaType1746613421849'; + name = 'AddReelVideoToProjectSocialMediaType1746613421849'; public async up(queryRunner: QueryRunner): Promise { - // Add YOUTUBE_REEL to the project_social_media_type_enum + // Add REEL_VIDEO to the project_social_media_type_enum await queryRunner.query( - `ALTER TYPE "public"."project_social_media_type_enum" ADD VALUE 'YOUTUBE_REEL'`, + `ALTER TYPE "public"."project_social_media_type_enum" ADD VALUE 'REEL_VIDEO'`, ); } diff --git a/migration/1746613421850-populateProjectYoutubeReels.ts b/migration/1746613421850-populateProjectReelVideos.ts similarity index 82% rename from migration/1746613421850-populateProjectYoutubeReels.ts rename to migration/1746613421850-populateProjectReelVideos.ts index a09659c25..e71863df3 100644 --- a/migration/1746613421850-populateProjectYoutubeReels.ts +++ b/migration/1746613421850-populateProjectReelVideos.ts @@ -1,13 +1,13 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class PopulateProjectYoutubeReels1746613421850 +export class PopulateProjectReelVideos1746613421850 implements MigrationInterface { - name = 'PopulateProjectYoutubeReels1746613421850'; + name = 'PopulateProjectReelVideos1746613421850'; public async up(queryRunner: QueryRunner): Promise { - // Define the project ticker to YouTube Reel URL mapping - const projectYoutubeReels = [ + // Define the project ticker to Reel Video URL mapping + const projectReelVideos = [ { ticker: 'PACK', url: 'https://youtube.com/shorts/8Gk-Ly8Foac?feature=share', @@ -50,8 +50,8 @@ export class PopulateProjectYoutubeReels1746613421850 }, ]; - // Insert YouTube Reel social media entries for each project - for (const { ticker, url } of projectYoutubeReels) { + // Insert Reel Video social media entries for each project + for (const { ticker, url } of projectReelVideos) { // First, get the project ID and admin user ID for the project with this ticker const projectResult = await queryRunner.query( `SELECT id, "adminUserId" @@ -64,10 +64,10 @@ export class PopulateProjectYoutubeReels1746613421850 const projectId = projectResult[0].id; const adminUserId = projectResult[0].adminUserId; - // Insert the YouTube Reel social media entry + // Insert the Reel Video social media entry await queryRunner.query( `INSERT INTO "project_social_media" ("type", "link", "projectId", "userId") - VALUES ('YOUTUBE_REEL', $1, $2, $3)`, + VALUES ('REEL_VIDEO', $1, $2, $3)`, [url, projectId, adminUserId], ); } @@ -89,11 +89,11 @@ export class PopulateProjectYoutubeReels1746613421850 'MELS', ]; - // Remove YouTube Reel social media entries for these projects + // Remove Reel Video social media entries for these projects for (const ticker of projectTickers) { await queryRunner.query( `DELETE FROM "project_social_media" - WHERE "type" = 'YOUTUBE_REEL' + WHERE "type" = 'REEL_VIDEO' AND "projectId" IN ( SELECT id FROM "project" WHERE "abc"->>'tokenTicker' = $1 )`, diff --git a/src/types/projectSocialMediaType.ts b/src/types/projectSocialMediaType.ts index 9991defeb..9640993be 100644 --- a/src/types/projectSocialMediaType.ts +++ b/src/types/projectSocialMediaType.ts @@ -5,7 +5,7 @@ export enum ProjectSocialMediaType { X = 'X', INSTAGRAM = 'INSTAGRAM', YOUTUBE = 'YOUTUBE', - YOUTUBE_REEL = 'YOUTUBE_REEL', + REEL_VIDEO = 'REEL_VIDEO', LINKEDIN = 'LINKEDIN', REDDIT = 'REDDIT', DISCORD = 'DISCORD', From 0ff29b607b6f079ae41cb8fd2e3c48f117fbe877 Mon Sep 17 00:00:00 2001 From: ali Date: Tue, 12 Aug 2025 02:59:57 +0330 Subject: [PATCH 4/7] add vesting schedule entity and resolver, and update package.json with new test scripts and also add a migration to fill the data of vesting schedules --- ...746613421852-createVestingScheduleTable.ts | 26 ++++ .../1746613421853-populateVestingSchedules.ts | 71 ++++++++++ package.json | 2 + src/entities/entities.ts | 2 + src/entities/vestingSchedule.ts | 41 ++++++ .../vestingScheduleRepository.test.ts | 88 ++++++++++++ src/repositories/vestingScheduleRepository.ts | 17 +++ src/resolvers/resolvers.ts | 2 + src/resolvers/vestingScheduleResolver.test.ts | 133 ++++++++++++++++++ src/resolvers/vestingScheduleResolver.ts | 21 +++ 10 files changed, 403 insertions(+) create mode 100644 migration/1746613421852-createVestingScheduleTable.ts create mode 100644 migration/1746613421853-populateVestingSchedules.ts create mode 100644 src/entities/vestingSchedule.ts create mode 100644 src/repositories/vestingScheduleRepository.test.ts create mode 100644 src/repositories/vestingScheduleRepository.ts create mode 100644 src/resolvers/vestingScheduleResolver.test.ts create mode 100644 src/resolvers/vestingScheduleResolver.ts diff --git a/migration/1746613421852-createVestingScheduleTable.ts b/migration/1746613421852-createVestingScheduleTable.ts new file mode 100644 index 000000000..1935ebf10 --- /dev/null +++ b/migration/1746613421852-createVestingScheduleTable.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateVestingScheduleTable1746613421852 + implements MigrationInterface +{ + name = 'CreateVestingScheduleTable1746613421852'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "vesting_schedule" ( + "id" SERIAL NOT NULL, + "name" character varying NOT NULL, + "start" TIMESTAMP NOT NULL, + "cliff" TIMESTAMP NOT NULL, + "end" TIMESTAMP NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_vesting_schedule_id" PRIMARY KEY ("id") + )`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "vesting_schedule"`); + } +} diff --git a/migration/1746613421853-populateVestingSchedules.ts b/migration/1746613421853-populateVestingSchedules.ts new file mode 100644 index 000000000..f54847292 --- /dev/null +++ b/migration/1746613421853-populateVestingSchedules.ts @@ -0,0 +1,71 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PopulateVestingSchedules1746613421853 + implements MigrationInterface +{ + name = 'PopulateVestingSchedules1746613421853'; + + public async up(queryRunner: QueryRunner): Promise { + // Define the vesting schedule data + const vestingSchedules = [ + { + name: 'Season 1 projects', + start: '2024-10-29', + cliff: '2025-10-29', + end: '2026-10-29', + }, + { + name: 'Season 2 projects', + start: '2025-04-11', + cliff: '2026-04-11', + end: '2027-04-11', + }, + { + name: 'R1 Season 1 buyers', + start: '2024-12-20', + cliff: '2025-06-20', + end: '2025-12-20', + }, + { + name: 'R2 Season 1 buyers', + start: '2025-05-13', + cliff: '2025-10-13', + end: '2026-03-13', + }, + { + name: 'R2 Season 2 buyers', + start: '2025-05-13', + cliff: '2025-11-13', + end: '2026-05-13', + }, + ]; + + // Insert each vesting schedule + for (const schedule of vestingSchedules) { + await queryRunner.query( + `INSERT INTO "vesting_schedule" ("name", "start", "cliff", "end") + VALUES ($1, $2, $3, $4)`, + [schedule.name, schedule.start, schedule.cliff, schedule.end], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + // Define the schedule names that were inserted + const scheduleNames = [ + 'Season 1 projects', + 'Season 2 projects', + 'R1 Season 1 buyers', + 'R2 Season 1 buyers', + 'R2 Season 2 buyers', + ]; + + // Remove the inserted vesting schedules + for (const name of scheduleNames) { + await queryRunner.query( + `DELETE FROM "vesting_schedule" WHERE "name" = $1`, + [name], + ); + } + } +} diff --git a/package.json b/package.json index df3b4c3bb..697df8247 100644 --- a/package.json +++ b/package.json @@ -191,6 +191,8 @@ "test:inverterScript": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/scripts/syncDataWithInverter.test.ts", "test:healthCheck": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/server/bootstrap.test.ts", "test:tokenPriceResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/tokenPriceResolver.test.ts", + "test:vestingScheduleRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/vestingScheduleRepository.test.ts", + "test:vestingScheduleResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/vestingScheduleResolver.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'", diff --git a/src/entities/entities.ts b/src/entities/entities.ts index c323f8083..1d9dab2ee 100644 --- a/src/entities/entities.ts +++ b/src/entities/entities.ts @@ -40,6 +40,7 @@ import { SwapTransaction } from './swapTransaction'; import { QaccPointsHistory } from './qaccPointsHistory'; import { UserRankMaterializedView } from './userRanksMaterialized'; import { VestingData } from './vestingData'; +import { VestingSchedule } from './vestingSchedule'; import { TokenPriceHistory } from './tokenPriceHistory'; export const getEntities = (): DataSourceOptions['entities'] => { @@ -94,6 +95,7 @@ export const getEntities = (): DataSourceOptions['entities'] => { SwapTransaction, UserRankMaterializedView, VestingData, + VestingSchedule, TokenPriceHistory, ]; }; diff --git a/src/entities/vestingSchedule.ts b/src/entities/vestingSchedule.ts new file mode 100644 index 000000000..2cd04e8c2 --- /dev/null +++ b/src/entities/vestingSchedule.ts @@ -0,0 +1,41 @@ +import { Field, ID, ObjectType } from 'type-graphql'; +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity() +@ObjectType() +export class VestingSchedule extends BaseEntity { + @Field(_type => ID) + @PrimaryGeneratedColumn() + readonly id: number; + + @Field() + @Column() + name: string; + + @Field() + @Column('timestamp') + start: Date; + + @Field() + @Column('timestamp') + cliff: Date; + + @Field() + @Column('timestamp') + end: Date; + + @Field() + @CreateDateColumn() + createdAt: Date; + + @Field() + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/repositories/vestingScheduleRepository.test.ts b/src/repositories/vestingScheduleRepository.test.ts new file mode 100644 index 000000000..b01b2dd25 --- /dev/null +++ b/src/repositories/vestingScheduleRepository.test.ts @@ -0,0 +1,88 @@ +import { assert } from 'chai'; +import { + findAllVestingSchedules, + findVestingScheduleById, +} from './vestingScheduleRepository'; +import { VestingSchedule } from '../entities/vestingSchedule'; +import { saveUserDirectlyToDb, SEED_DATA } from '../../test/testUtils'; +import { User } from '../entities/user'; + +describe( + 'VestingSchedule Repository test cases', + vestingScheduleRepositoryTestCases, +); + +function vestingScheduleRepositoryTestCases() { + let user: User; + let vestingSchedule1: VestingSchedule; + + beforeEach(async () => { + // Create test user + user = await saveUserDirectlyToDb(SEED_DATA.FIRST_USER.email); + + // Create test vesting schedules + vestingSchedule1 = await VestingSchedule.create({ + name: 'Team Vesting', + start: new Date('2024-01-01'), + cliff: new Date('2024-06-01'), + end: new Date('2025-01-01'), + }).save(); + + await VestingSchedule.create({ + name: 'Advisor Vesting', + start: new Date('2024-03-01'), + cliff: new Date('2024-09-01'), + end: new Date('2025-03-01'), + }).save(); + }); + + afterEach(async () => { + // Clean up test data + await VestingSchedule.delete({}); + await User.delete({ id: user.id }); + }); + + describe('findAllVestingSchedules', () => { + it('should return all vesting schedules ordered by start date ASC', async () => { + const vestingSchedules = await findAllVestingSchedules(); + + assert.equal(vestingSchedules.length, 2); + assert.equal(vestingSchedules[0].name, 'Team Vesting'); + assert.equal(vestingSchedules[1].name, 'Advisor Vesting'); + + // Verify ordering by start date + assert.isTrue( + vestingSchedules[0].start.getTime() <= + vestingSchedules[1].start.getTime(), + ); + }); + + it('should return empty array when no vesting schedules exist', async () => { + await VestingSchedule.delete({}); + const vestingSchedules = await findAllVestingSchedules(); + + assert.equal(vestingSchedules.length, 0); + }); + }); + + describe('findVestingScheduleById', () => { + it('should return vesting schedule by id', async () => { + const foundVestingSchedule = await findVestingScheduleById( + vestingSchedule1.id, + ); + + assert.isNotNull(foundVestingSchedule); + assert.equal(foundVestingSchedule!.id, vestingSchedule1.id); + assert.equal(foundVestingSchedule!.name, 'Team Vesting'); + assert.deepEqual(foundVestingSchedule!.start, vestingSchedule1.start); + assert.deepEqual(foundVestingSchedule!.cliff, vestingSchedule1.cliff); + assert.deepEqual(foundVestingSchedule!.end, vestingSchedule1.end); + }); + + it('should return null when vesting schedule does not exist', async () => { + const foundVestingSchedule = await findVestingScheduleById(999999); + + assert.isNull(foundVestingSchedule); + }); + }); +} diff --git a/src/repositories/vestingScheduleRepository.ts b/src/repositories/vestingScheduleRepository.ts new file mode 100644 index 000000000..6b918c220 --- /dev/null +++ b/src/repositories/vestingScheduleRepository.ts @@ -0,0 +1,17 @@ +import { VestingSchedule } from '../entities/vestingSchedule'; + +export const findAllVestingSchedules = async (): Promise => { + return VestingSchedule.find({ + order: { + start: 'ASC', + }, + }); +}; + +export const findVestingScheduleById = async ( + id: number, +): Promise => { + return VestingSchedule.findOne({ + where: { id }, + }); +}; diff --git a/src/resolvers/resolvers.ts b/src/resolvers/resolvers.ts index be6957c66..02eea28cd 100644 --- a/src/resolvers/resolvers.ts +++ b/src/resolvers/resolvers.ts @@ -18,6 +18,7 @@ import { RoundsResolver } from './roundsResolver'; import { QAccResolver } from './qAccResolver'; import { QaccPointsHistoryResolver } from './qaccPointsHistoryResolver'; import { TokenPriceResolver } from './tokenPriceResolver'; +import { VestingScheduleResolver } from './vestingScheduleResolver'; // eslint-disable-next-line @typescript-eslint/ban-types export const getResolvers = (): Function[] => { @@ -46,5 +47,6 @@ export const getResolvers = (): Function[] => { QAccResolver, QaccPointsHistoryResolver, TokenPriceResolver, + VestingScheduleResolver, ]; }; diff --git a/src/resolvers/vestingScheduleResolver.test.ts b/src/resolvers/vestingScheduleResolver.test.ts new file mode 100644 index 000000000..f7e1a5537 --- /dev/null +++ b/src/resolvers/vestingScheduleResolver.test.ts @@ -0,0 +1,133 @@ +import { assert } from 'chai'; +import { VestingScheduleResolver } from './vestingScheduleResolver'; +import { VestingSchedule } from '../entities/vestingSchedule'; +import { saveUserDirectlyToDb, SEED_DATA } from '../../test/testUtils'; +import { User } from '../entities/user'; + +describe( + 'VestingSchedule Resolver test cases', + vestingScheduleResolverTestCases, +); + +function vestingScheduleResolverTestCases() { + let user: User; + let vestingScheduleResolver: VestingScheduleResolver; + let vestingSchedule1: VestingSchedule; + + beforeEach(async () => { + vestingScheduleResolver = new VestingScheduleResolver(); + + // Create test user + user = await saveUserDirectlyToDb(SEED_DATA.FIRST_USER.email); + + // Create test vesting schedules + vestingSchedule1 = await VestingSchedule.create({ + name: 'Team Vesting', + start: new Date('2024-01-01'), + cliff: new Date('2024-06-01'), + end: new Date('2025-01-01'), + }).save(); + + await VestingSchedule.create({ + name: 'Advisor Vesting', + start: new Date('2024-03-01'), + cliff: new Date('2024-09-01'), + end: new Date('2025-03-01'), + }).save(); + }); + + afterEach(async () => { + // Clean up test data + await VestingSchedule.delete({}); + await User.delete({ id: user.id }); + }); + + describe('vestingSchedules query', () => { + it('should return all vesting schedules', async () => { + const result = await vestingScheduleResolver.vestingSchedules(); + + assert.equal(result.length, 2); + assert.equal(result[0].name, 'Team Vesting'); + assert.equal(result[1].name, 'Advisor Vesting'); + }); + + it('should return empty array when no vesting schedules exist', async () => { + await VestingSchedule.delete({}); + const result = await vestingScheduleResolver.vestingSchedules(); + + assert.equal(result.length, 0); + }); + + it('should return vesting schedules ordered by start date', async () => { + const result = await vestingScheduleResolver.vestingSchedules(); + + // Should be ordered by start date ASC + assert.isTrue(result[0].start.getTime() <= result[1].start.getTime()); + }); + }); + + describe('vestingSchedule query', () => { + it('should return vesting schedule by id', async () => { + const result = await vestingScheduleResolver.vestingSchedule( + vestingSchedule1.id, + ); + + assert.isNotNull(result); + assert.equal(result!.id, vestingSchedule1.id); + assert.equal(result!.name, 'Team Vesting'); + assert.deepEqual(result!.start, vestingSchedule1.start); + assert.deepEqual(result!.cliff, vestingSchedule1.cliff); + assert.deepEqual(result!.end, vestingSchedule1.end); + }); + + it('should return null when vesting schedule does not exist', async () => { + const result = await vestingScheduleResolver.vestingSchedule(999999); + + assert.isNull(result); + }); + }); + + describe('Vesting schedule field validation', () => { + it('should have all required fields populated', async () => { + const result = await vestingScheduleResolver.vestingSchedule( + vestingSchedule1.id, + ); + + assert.isNotNull(result); + assert.isNumber(result!.id); + assert.isString(result!.name); + assert.instanceOf(result!.start, Date); + assert.instanceOf(result!.cliff, Date); + assert.instanceOf(result!.end, Date); + assert.instanceOf(result!.createdAt, Date); + assert.instanceOf(result!.updatedAt, Date); + }); + + it('should validate date chronology (start <= cliff <= end)', async () => { + const result = await vestingScheduleResolver.vestingSchedule( + vestingSchedule1.id, + ); + + assert.isNotNull(result); + assert.isTrue(result!.start.getTime() <= result!.cliff.getTime()); + assert.isTrue(result!.cliff.getTime() <= result!.end.getTime()); + }); + }); + + describe('Data integrity', () => { + it('should maintain data consistency across queries', async () => { + const singleResult = await vestingScheduleResolver.vestingSchedule( + vestingSchedule1.id, + ); + const allResults = await vestingScheduleResolver.vestingSchedules(); + const foundInAll = allResults.find(vs => vs.id === vestingSchedule1.id); + + assert.isNotNull(singleResult); + assert.isNotNull(foundInAll); + assert.equal(singleResult!.name, foundInAll!.name); + assert.deepEqual(singleResult!.start, foundInAll!.start); + assert.deepEqual(singleResult!.cliff, foundInAll!.cliff); + assert.deepEqual(singleResult!.end, foundInAll!.end); + }); + }); +} diff --git a/src/resolvers/vestingScheduleResolver.ts b/src/resolvers/vestingScheduleResolver.ts new file mode 100644 index 000000000..1e1eb0828 --- /dev/null +++ b/src/resolvers/vestingScheduleResolver.ts @@ -0,0 +1,21 @@ +import { Arg, Int, Query, Resolver } from 'type-graphql'; +import { VestingSchedule } from '../entities/vestingSchedule'; +import { + findAllVestingSchedules, + findVestingScheduleById, +} from '../repositories/vestingScheduleRepository'; + +@Resolver(_of => VestingSchedule) +export class VestingScheduleResolver { + @Query(_returns => [VestingSchedule]) + async vestingSchedules(): Promise { + return findAllVestingSchedules(); + } + + @Query(_returns => VestingSchedule, { nullable: true }) + async vestingSchedule( + @Arg('id', _type => Int) id: number, + ): Promise { + return findVestingScheduleById(id); + } +} From 6313ad4518b855beaef477796e04410264686155 Mon Sep 17 00:00:00 2001 From: ali Date: Tue, 12 Aug 2025 03:21:21 +0330 Subject: [PATCH 5/7] add token holder entity and resolver, and update package.json with new test scripts add migration to fill token holders data --- .../1746613421854-createTokenHolderTable.ts | 28 +++ .../1746613421855-populateTokenHolders.ts | 238 ++++++++++++++++++ package.json | 2 + src/entities/entities.ts | 2 + src/entities/tokenHolder.ts | 39 +++ .../tokenHolderRepository.test.ts | 132 ++++++++++ src/repositories/tokenHolderRepository.ts | 40 +++ src/resolvers/resolvers.ts | 2 + src/resolvers/tokenHolderResolver.test.ts | 207 +++++++++++++++ src/resolvers/tokenHolderResolver.ts | 37 +++ 10 files changed, 727 insertions(+) create mode 100644 migration/1746613421854-createTokenHolderTable.ts create mode 100644 migration/1746613421855-populateTokenHolders.ts create mode 100644 src/entities/tokenHolder.ts create mode 100644 src/repositories/tokenHolderRepository.test.ts create mode 100644 src/repositories/tokenHolderRepository.ts create mode 100644 src/resolvers/tokenHolderResolver.test.ts create mode 100644 src/resolvers/tokenHolderResolver.ts diff --git a/migration/1746613421854-createTokenHolderTable.ts b/migration/1746613421854-createTokenHolderTable.ts new file mode 100644 index 000000000..704aff1dc --- /dev/null +++ b/migration/1746613421854-createTokenHolderTable.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateTokenHolderTable1746613421854 implements MigrationInterface { + name = 'CreateTokenHolderTable1746613421854'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "token_holder" ( + "id" SERIAL NOT NULL, + "projectName" character varying NOT NULL, + "address" character varying NOT NULL, + "tag" character varying, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_token_holder_id" PRIMARY KEY ("id") + )`, + ); + + await queryRunner.query( + `CREATE INDEX "IDX_token_holder_address" ON "token_holder" ("address")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_token_holder_address"`); + await queryRunner.query(`DROP TABLE "token_holder"`); + } +} diff --git a/migration/1746613421855-populateTokenHolders.ts b/migration/1746613421855-populateTokenHolders.ts new file mode 100644 index 000000000..15d01ed18 --- /dev/null +++ b/migration/1746613421855-populateTokenHolders.ts @@ -0,0 +1,238 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PopulateTokenHolders1746613421855 implements MigrationInterface { + name = 'PopulateTokenHolders1746613421855'; + + public async up(queryRunner: QueryRunner): Promise { + // Define the token holder data + const tokenHolders = [ + // PACK + { + projectName: 'PACK', + tag: 'Vesting Contract', + address: '0xD1959e8a3D0C2cB2768543feD5bdD27D19b6c73e', + }, + { + projectName: 'PACK', + tag: 'Liquidity Bot', + address: '0xe2D718Cd6B9b3e65ad4DCe6903EC5e37FEd0297b', + }, + { + projectName: 'PACK', + tag: 'DEX LP', + address: '0x8a8C62E6B1C8EE5b104B7C7401D0b4cDfa4CeCBf', + }, + // TDM + { + projectName: 'TDM', + tag: 'Vesting Contract', + address: '0xAbeFd091Abfb87528151e48973829c407FFA1333', + }, + { + projectName: 'TDM', + tag: 'Liquidity Bot', + address: '0xa44A5E67236CC2674e24294C1AB2f13b162338d8', + }, + { + projectName: 'TDM', + tag: 'DEX LP', + address: '0x3c2A6424f245136a15eAc951FA27D33B8357b362', + }, + // LOCK + { + projectName: 'LOCK', + tag: 'Vesting Contract', + address: '0x9E17eFea75A3a33fB235B079D82c83458C14369C', + }, + { + projectName: 'LOCK', + tag: 'Liquidity Bot', + address: '0x7003c15252eC37DcC7f1134CFD9AD3d6d0449bB5', + }, + { + projectName: 'LOCK', + tag: 'DEX LP', + address: '0xfB7771110Fa0b9d2F3c921Ace03991701b8623aF', + }, + // H2DAO + { + projectName: 'H2DAO', + tag: 'Vesting Contract', + address: '0x79c744e6db81dd83E83271cDB53578318F6c1D65', + }, + { + projectName: 'H2DAO', + tag: 'Liquidity Bot', + address: '0x27F32d16C1C8Ce8C36099A444B2A812C14a287FB', + }, + { + projectName: 'H2DAO', + tag: 'DEX LP', + address: '0x5F6520d0a751Aaf8353874583A152e49a0828eE5', + }, + // X23 + { + projectName: 'X23', + tag: 'Vesting Contract', + address: '0x6B5d37c206D56B16F44b0C1b89002fd9B138e9Be', + }, + { + projectName: 'X23', + tag: 'Liquidity Bot', + address: '0xd189BcEA30511d4E229BC2d901120f2881b9D0e2', + }, + { + projectName: 'X23', + tag: 'DEX LP', + address: '0x0De6dA16D5181a9Fe2543cE1eeb4bFD268D68838', + }, + // CTZN + { + projectName: 'CTZN', + tag: 'Vesting Contract', + address: '0x0DDd250bfb440e6deF3157eE29747e8ac29153aD', + }, + { + projectName: 'CTZN', + tag: 'Liquidity Bot', + address: '0x28e7772b474C3f7147Ca2aD4F7C9Bd6a23c72E36', + }, + { + projectName: 'CTZN', + tag: 'DEX LP', + address: '0x746CF1bAaa81E6f2dEe39Bd4E3cB5E9f0Edf98a8', + }, + // PRSM + { + projectName: 'PRSM', + tag: 'Vesting Contract', + address: '0x96b6aA42777D0fDDE8F8e45f35129D1D11CdA981', + }, + { + projectName: 'PRSM', + tag: 'Liquidity Bot', + address: '0x84028C23F5f8051598b20696C8240c012C994Ba1', + }, + { + projectName: 'PRSM', + tag: 'DEX LP', + address: '0x4DC15eDc968EceAec3A5e0F12d0aCECACee05e25', + }, + // GRNDT + { + projectName: 'GRNDT', + tag: 'Vesting Contract', + address: '0x480f463b0831990b1929fB401f21E55B21E985cD', + }, + { + projectName: 'GRNDT', + tag: 'Liquidity Bot', + address: '0xA864e45F7799ba239580186E94b9390A6f568ade', + }, + { + projectName: 'GRNDT', + tag: 'DEX LP', + address: '0x460A8186AA4574C18709d1eFF118EfDAa5235C19', + }, + // ACHAD + { + projectName: 'ACHAD', + tag: 'Vesting Contract', + address: '0xC7374519fc9DfcDaCD3bd1f337AC98dD3dB09dE9', + }, + { + projectName: 'ACHAD', + tag: 'Liquidity Bot', + address: '0x7ca5d9e997A9310b180fd394cF80183Eb5aaDF66', + }, + { + projectName: 'ACHAD', + tag: 'DEX LP', + address: '0x7F4818ae354C30d79b1E0C1838382D64b93366Aa', + }, + // MELS + { + projectName: 'MELS', + tag: 'Vesting Contract', + address: '0xABfaeb84364c419b19A9241434a997c88731C6fa', + }, + { + projectName: 'MELS', + tag: 'Liquidity Bot', + address: '0xdB99BeF6D8Bb9703c9B2F110aE8C30580830b92D', + }, + { + projectName: 'MELS', + tag: 'DEX LP', + address: '0x6E9869FeA80D791e58AfA60d3Dd2e14B16Ef064a', + }, + // BEAST + { + projectName: 'BEAST', + tag: 'Vesting Contract', + address: '0x1e4350605E143E58F0C786A76FA8f70257B3D20e', + }, + { + projectName: 'BEAST', + tag: 'Liquidity Bot', + address: '0xadfC4Bc382F4ECe69a19b3Eecad5Dd62d9e919ae', + }, + { + projectName: 'BEAST', + tag: 'DEX LP', + address: '0xb7a6F7e6Efa1024c44028bc1AB4E08F0F377567e', + }, + // AKA + { + projectName: 'AKA', + tag: 'Vesting Contract', + address: '0x9858b8FeE34F27959e3CDFAf022a5D0844eaeA65', + }, + { + projectName: 'AKA', + tag: 'Liquidity Bot', + address: '0x6925B3c6ad873e1F84b65c37B4562F03D1D15687', + }, + { + projectName: 'AKA', + tag: 'DEX LP', + address: '0xd404B5ec643A129e2853D78Ba98368cee097ae92', + }, + ]; + + // Insert each token holder + for (const tokenHolder of tokenHolders) { + await queryRunner.query( + `INSERT INTO "token_holder" ("projectName", "tag", "address") + VALUES ($1, $2, $3)`, + [tokenHolder.projectName, tokenHolder.tag, tokenHolder.address], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + // Define the project names that were inserted + const projectNames = [ + 'PACK', + 'TDM', + 'LOCK', + 'H2DAO', + 'X23', + 'CTZN', + 'PRSM', + 'GRNDT', + 'ACHAD', + 'MELS', + 'BEAST', + 'AKA', + ]; + + // Remove the inserted token holders + for (const projectName of projectNames) { + await queryRunner.query( + `DELETE FROM "token_holder" WHERE "projectName" = $1`, + [projectName], + ); + } + } +} diff --git a/package.json b/package.json index 697df8247..b0bc13f43 100644 --- a/package.json +++ b/package.json @@ -193,6 +193,8 @@ "test:tokenPriceResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/tokenPriceResolver.test.ts", "test:vestingScheduleRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/vestingScheduleRepository.test.ts", "test:vestingScheduleResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/vestingScheduleResolver.test.ts", + "test:tokenHolderRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/tokenHolderRepository.test.ts", + "test:tokenHolderResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/tokenHolderResolver.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'", diff --git a/src/entities/entities.ts b/src/entities/entities.ts index 1d9dab2ee..91bd89805 100644 --- a/src/entities/entities.ts +++ b/src/entities/entities.ts @@ -41,6 +41,7 @@ import { QaccPointsHistory } from './qaccPointsHistory'; import { UserRankMaterializedView } from './userRanksMaterialized'; import { VestingData } from './vestingData'; import { VestingSchedule } from './vestingSchedule'; +import { TokenHolder } from './tokenHolder'; import { TokenPriceHistory } from './tokenPriceHistory'; export const getEntities = (): DataSourceOptions['entities'] => { @@ -96,6 +97,7 @@ export const getEntities = (): DataSourceOptions['entities'] => { UserRankMaterializedView, VestingData, VestingSchedule, + TokenHolder, TokenPriceHistory, ]; }; diff --git a/src/entities/tokenHolder.ts b/src/entities/tokenHolder.ts new file mode 100644 index 000000000..bdffb3df9 --- /dev/null +++ b/src/entities/tokenHolder.ts @@ -0,0 +1,39 @@ +import { Field, ID, ObjectType } from 'type-graphql'; +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity() +@ObjectType() +export class TokenHolder extends BaseEntity { + @Field(_type => ID) + @PrimaryGeneratedColumn() + readonly id: number; + + @Field() + @Column() + projectName: string; + + @Field() + @Index() + @Column() + address: string; + + @Field({ nullable: true }) + @Column({ nullable: true }) + tag?: string; + + @Field() + @CreateDateColumn() + createdAt: Date; + + @Field() + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/repositories/tokenHolderRepository.test.ts b/src/repositories/tokenHolderRepository.test.ts new file mode 100644 index 000000000..6f8288bc3 --- /dev/null +++ b/src/repositories/tokenHolderRepository.test.ts @@ -0,0 +1,132 @@ +import { assert } from 'chai'; +import { + findAllTokenHolders, + findTokenHolderById, + findTokenHoldersByProjectName, + findTokenHoldersByAddress, +} from './tokenHolderRepository'; +import { TokenHolder } from '../entities/tokenHolder'; +import { saveUserDirectlyToDb, SEED_DATA } from '../../test/testUtils'; +import { User } from '../entities/user'; + +describe('TokenHolder Repository test cases', tokenHolderRepositoryTestCases); + +function tokenHolderRepositoryTestCases() { + let user: User; + let tokenHolder1: TokenHolder; + + beforeEach(async () => { + // Create test user + user = await saveUserDirectlyToDb(SEED_DATA.FIRST_USER.email); + + // Create test token holders + tokenHolder1 = await TokenHolder.create({ + projectName: 'PACK', + address: '0x1234567890123456789012345678901234567890', + tag: 'Team', + }).save(); + + await TokenHolder.create({ + projectName: 'PACK', + address: '0x2345678901234567890123456789012345678901', + tag: 'Advisor', + }).save(); + + await TokenHolder.create({ + projectName: 'X23', + address: '0x1234567890123456789012345678901234567890', + tag: 'Investor', + }).save(); + }); + + afterEach(async () => { + // Clean up test data + await TokenHolder.delete({}); + await User.delete({ id: user.id }); + }); + + describe('findAllTokenHolders', () => { + it('should return all token holders ordered by project name and tag', async () => { + const tokenHolders = await findAllTokenHolders(); + + assert.equal(tokenHolders.length, 3); + + // Verify ordering by projectName ASC, tag ASC + assert.equal(tokenHolders[0].projectName, 'PACK'); + assert.equal(tokenHolders[0].tag, 'Advisor'); + assert.equal(tokenHolders[1].projectName, 'PACK'); + assert.equal(tokenHolders[1].tag, 'Team'); + assert.equal(tokenHolders[2].projectName, 'X23'); + assert.equal(tokenHolders[2].tag, 'Investor'); + }); + + it('should return empty array when no token holders exist', async () => { + await TokenHolder.delete({}); + const tokenHolders = await findAllTokenHolders(); + + assert.equal(tokenHolders.length, 0); + }); + }); + + describe('findTokenHolderById', () => { + it('should return token holder by id', async () => { + const foundTokenHolder = await findTokenHolderById(tokenHolder1.id); + + assert.isNotNull(foundTokenHolder); + assert.equal(foundTokenHolder!.id, tokenHolder1.id); + assert.equal(foundTokenHolder!.projectName, 'PACK'); + assert.equal( + foundTokenHolder!.address, + '0x1234567890123456789012345678901234567890', + ); + assert.equal(foundTokenHolder!.tag, 'Team'); + }); + + it('should return null when token holder does not exist', async () => { + const foundTokenHolder = await findTokenHolderById(999999); + + assert.isNull(foundTokenHolder); + }); + }); + + describe('findTokenHoldersByProjectName', () => { + it('should return token holders for specific project ordered by tag', async () => { + const packHolders = await findTokenHoldersByProjectName('PACK'); + + assert.equal(packHolders.length, 2); + assert.equal(packHolders[0].tag, 'Advisor'); + assert.equal(packHolders[1].tag, 'Team'); + assert.isTrue(packHolders.every(holder => holder.projectName === 'PACK')); + }); + + it('should return empty array when no token holders exist for project', async () => { + const holders = await findTokenHoldersByProjectName('NONEXISTENT'); + + assert.equal(holders.length, 0); + }); + }); + + describe('findTokenHoldersByAddress', () => { + it('should return token holders for specific address ordered by project name', async () => { + const addressHolders = await findTokenHoldersByAddress( + '0x1234567890123456789012345678901234567890', + ); + + assert.equal(addressHolders.length, 2); + assert.equal(addressHolders[0].projectName, 'PACK'); + assert.equal(addressHolders[1].projectName, 'X23'); + assert.isTrue( + addressHolders.every( + holder => + holder.address === '0x1234567890123456789012345678901234567890', + ), + ); + }); + + it('should return empty array when no token holders exist for address', async () => { + const holders = await findTokenHoldersByAddress('0xnonexistent'); + + assert.equal(holders.length, 0); + }); + }); +} diff --git a/src/repositories/tokenHolderRepository.ts b/src/repositories/tokenHolderRepository.ts new file mode 100644 index 000000000..24919fb33 --- /dev/null +++ b/src/repositories/tokenHolderRepository.ts @@ -0,0 +1,40 @@ +import { TokenHolder } from '../entities/tokenHolder'; + +export const findAllTokenHolders = async (): Promise => { + return TokenHolder.find({ + order: { + projectName: 'ASC', + tag: 'ASC', + }, + }); +}; + +export const findTokenHolderById = async ( + id: number, +): Promise => { + return TokenHolder.findOne({ + where: { id }, + }); +}; + +export const findTokenHoldersByProjectName = async ( + projectName: string, +): Promise => { + return TokenHolder.find({ + where: { projectName }, + order: { + tag: 'ASC', + }, + }); +}; + +export const findTokenHoldersByAddress = async ( + address: string, +): Promise => { + return TokenHolder.find({ + where: { address }, + order: { + projectName: 'ASC', + }, + }); +}; diff --git a/src/resolvers/resolvers.ts b/src/resolvers/resolvers.ts index 02eea28cd..9920b4641 100644 --- a/src/resolvers/resolvers.ts +++ b/src/resolvers/resolvers.ts @@ -19,6 +19,7 @@ import { QAccResolver } from './qAccResolver'; import { QaccPointsHistoryResolver } from './qaccPointsHistoryResolver'; import { TokenPriceResolver } from './tokenPriceResolver'; import { VestingScheduleResolver } from './vestingScheduleResolver'; +import { TokenHolderResolver } from './tokenHolderResolver'; // eslint-disable-next-line @typescript-eslint/ban-types export const getResolvers = (): Function[] => { @@ -48,5 +49,6 @@ export const getResolvers = (): Function[] => { QaccPointsHistoryResolver, TokenPriceResolver, VestingScheduleResolver, + TokenHolderResolver, ]; }; diff --git a/src/resolvers/tokenHolderResolver.test.ts b/src/resolvers/tokenHolderResolver.test.ts new file mode 100644 index 000000000..2398744d0 --- /dev/null +++ b/src/resolvers/tokenHolderResolver.test.ts @@ -0,0 +1,207 @@ +import { assert } from 'chai'; +import { TokenHolderResolver } from './tokenHolderResolver'; +import { TokenHolder } from '../entities/tokenHolder'; +import { saveUserDirectlyToDb, SEED_DATA } from '../../test/testUtils'; +import { User } from '../entities/user'; + +describe('TokenHolder Resolver test cases', tokenHolderResolverTestCases); + +function tokenHolderResolverTestCases() { + let user: User; + let tokenHolderResolver: TokenHolderResolver; + let tokenHolder1: TokenHolder; + + beforeEach(async () => { + tokenHolderResolver = new TokenHolderResolver(); + + // Create test user + user = await saveUserDirectlyToDb(SEED_DATA.FIRST_USER.email); + + // Create test token holders + tokenHolder1 = await TokenHolder.create({ + projectName: 'PACK', + address: '0x1234567890123456789012345678901234567890', + tag: 'Team', + }).save(); + + await TokenHolder.create({ + projectName: 'PACK', + address: '0x2345678901234567890123456789012345678901', + tag: 'Advisor', + }).save(); + + await TokenHolder.create({ + projectName: 'X23', + address: '0x1234567890123456789012345678901234567890', + tag: 'Investor', + }).save(); + }); + + afterEach(async () => { + // Clean up test data + await TokenHolder.delete({}); + await User.delete({ id: user.id }); + }); + + describe('tokenHolders query', () => { + it('should return all token holders', async () => { + const result = await tokenHolderResolver.tokenHolders(); + + assert.equal(result.length, 3); + assert.isTrue(result.some(holder => holder.projectName === 'PACK')); + assert.isTrue(result.some(holder => holder.projectName === 'X23')); + }); + + it('should return empty array when no token holders exist', async () => { + await TokenHolder.delete({}); + const result = await tokenHolderResolver.tokenHolders(); + + assert.equal(result.length, 0); + }); + + it('should return token holders ordered by project name and tag', async () => { + const result = await tokenHolderResolver.tokenHolders(); + + // Should be ordered by projectName ASC, tag ASC + assert.equal(result[0].projectName, 'PACK'); + assert.equal(result[0].tag, 'Advisor'); + assert.equal(result[1].projectName, 'PACK'); + assert.equal(result[1].tag, 'Team'); + assert.equal(result[2].projectName, 'X23'); + assert.equal(result[2].tag, 'Investor'); + }); + }); + + describe('tokenHolder query', () => { + it('should return token holder by id', async () => { + const result = await tokenHolderResolver.tokenHolder(tokenHolder1.id); + + assert.isNotNull(result); + assert.equal(result!.id, tokenHolder1.id); + assert.equal(result!.projectName, 'PACK'); + assert.equal( + result!.address, + '0x1234567890123456789012345678901234567890', + ); + assert.equal(result!.tag, 'Team'); + }); + + it('should return null when token holder does not exist', async () => { + const result = await tokenHolderResolver.tokenHolder(999999); + + assert.isNull(result); + }); + }); + + describe('tokenHoldersByProject query', () => { + it('should return token holders for specific project', async () => { + const result = await tokenHolderResolver.tokenHoldersByProject('PACK'); + + assert.equal(result.length, 2); + assert.isTrue(result.every(holder => holder.projectName === 'PACK')); + + // Should be ordered by tag + assert.equal(result[0].tag, 'Advisor'); + assert.equal(result[1].tag, 'Team'); + }); + + it('should return empty array when no token holders exist for project', async () => { + const result = + await tokenHolderResolver.tokenHoldersByProject('NONEXISTENT'); + + assert.equal(result.length, 0); + }); + }); + + describe('tokenHoldersByAddress query', () => { + it('should return token holders for specific address', async () => { + const result = await tokenHolderResolver.tokenHoldersByAddress( + '0x1234567890123456789012345678901234567890', + ); + + assert.equal(result.length, 2); + assert.isTrue( + result.every( + holder => + holder.address === '0x1234567890123456789012345678901234567890', + ), + ); + + // Should be ordered by project name + assert.equal(result[0].projectName, 'PACK'); + assert.equal(result[1].projectName, 'X23'); + }); + + it('should return empty array when no token holders exist for address', async () => { + const result = + await tokenHolderResolver.tokenHoldersByAddress('0xnonexistent'); + + assert.equal(result.length, 0); + }); + }); + + describe('Token holder field validation', () => { + it('should have all required fields populated', async () => { + const result = await tokenHolderResolver.tokenHolder(tokenHolder1.id); + + assert.isNotNull(result); + assert.isNumber(result!.id); + assert.isString(result!.projectName); + assert.isString(result!.address); + assert.isString(result!.tag); + assert.instanceOf(result!.createdAt, Date); + assert.instanceOf(result!.updatedAt, Date); + }); + + it('should handle null tag gracefully', async () => { + const tokenHolderWithoutTag = await TokenHolder.create({ + projectName: 'TEST', + address: '0x9999999999999999999999999999999999999999', + tag: undefined, + }).save(); + + const result = await tokenHolderResolver.tokenHolder( + tokenHolderWithoutTag.id, + ); + + assert.isNotNull(result); + assert.equal(result!.projectName, 'TEST'); + assert.equal( + result!.address, + '0x9999999999999999999999999999999999999999', + ); + assert.isNull(result!.tag); + }); + }); + + describe('Data integrity', () => { + it('should maintain data consistency across queries', async () => { + const singleResult = await tokenHolderResolver.tokenHolder( + tokenHolder1.id, + ); + const allResults = await tokenHolderResolver.tokenHolders(); + const foundInAll = allResults.find(th => th.id === tokenHolder1.id); + + assert.isNotNull(singleResult); + assert.isNotNull(foundInAll); + assert.equal(singleResult!.projectName, foundInAll!.projectName); + assert.equal(singleResult!.address, foundInAll!.address); + assert.equal(singleResult!.tag, foundInAll!.tag); + }); + + it('should filter correctly by project name', async () => { + const projectResults = + await tokenHolderResolver.tokenHoldersByProject('PACK'); + const allResults = await tokenHolderResolver.tokenHolders(); + const packHoldersFromAll = allResults.filter( + th => th.projectName === 'PACK', + ); + + assert.equal(projectResults.length, packHoldersFromAll.length); + assert.deepEqual( + projectResults.map(th => th.id).sort(), + packHoldersFromAll.map(th => th.id).sort(), + ); + }); + }); +} diff --git a/src/resolvers/tokenHolderResolver.ts b/src/resolvers/tokenHolderResolver.ts new file mode 100644 index 000000000..4649de979 --- /dev/null +++ b/src/resolvers/tokenHolderResolver.ts @@ -0,0 +1,37 @@ +import { Arg, Int, Query, Resolver } from 'type-graphql'; +import { TokenHolder } from '../entities/tokenHolder'; +import { + findAllTokenHolders, + findTokenHolderById, + findTokenHoldersByProjectName, + findTokenHoldersByAddress, +} from '../repositories/tokenHolderRepository'; + +@Resolver(_of => TokenHolder) +export class TokenHolderResolver { + @Query(_returns => [TokenHolder]) + async tokenHolders(): Promise { + return findAllTokenHolders(); + } + + @Query(_returns => TokenHolder, { nullable: true }) + async tokenHolder( + @Arg('id', _type => Int) id: number, + ): Promise { + return findTokenHolderById(id); + } + + @Query(_returns => [TokenHolder]) + async tokenHoldersByProject( + @Arg('projectName') projectName: string, + ): Promise { + return findTokenHoldersByProjectName(projectName); + } + + @Query(_returns => [TokenHolder]) + async tokenHoldersByAddress( + @Arg('address') address: string, + ): Promise { + return findTokenHoldersByAddress(address); + } +} From 4187837fd6eb0c2353b7498023c5f509e6d545d5 Mon Sep 17 00:00:00 2001 From: ali Date: Tue, 12 Aug 2025 04:09:16 +0330 Subject: [PATCH 6/7] Enhance user resolver to efficiently fetch and merge additional user data from the database, improving performance and handling cases with no users found. --- src/resolvers/userResolver.ts | 45 +++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index cd2edca7e..7e2ea86cb 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -337,11 +337,46 @@ export class UserResolver { if (walletAddress) { whereCondition.walletAddress = walletAddress; } - const [users, totalCount] = await UserRankMaterializedView.findAndCount({ - where: whereCondition, - order: { [orderBy.field]: orderBy.direction }, - take, - skip, + + // Get data from materialized view for efficient filtering/pagination + const [rankedUsers, totalCount] = + await UserRankMaterializedView.findAndCount({ + where: whereCondition, + order: { [orderBy.field]: orderBy.direction }, + take, + skip, + }); + + // If no users found, return early + if (rankedUsers.length === 0) { + return { users: [], totalCount }; + } + + // Extract user IDs to fetch only needed additional fields + const userIds = rankedUsers.map(user => user.id); + + // Fetch only the needed fields that are missing from materialized view + const additionalUserData = await this.userRepository + .createQueryBuilder('user') + .select(['user.id', 'user.avatar', 'user.username']) + .where('user.id IN (:...userIds)', { userIds }) + .getMany(); + + // Create a map for quick lookup of additional user data + const userMap = new Map(additionalUserData.map(user => [user.id, user])); + + // Merge the ranked data with additional user data, preserving the order from materialized view + const users = rankedUsers.map(rankedUser => { + const additionalData = userMap.get(rankedUser.id); + if (additionalData) { + // Combine materialized view data with additional fields + return { + ...rankedUser, + avatar: additionalData.avatar, + username: additionalData.username, + }; + } + return rankedUser; // Fallback to ranked user if additional data not found }); return { users, totalCount }; From 1f27e400974da245d7da14d611cabef204f5d772 Mon Sep 17 00:00:00 2001 From: ali Date: Mon, 18 Aug 2025 23:05:19 +0330 Subject: [PATCH 7/7] Add socialMedia field to allProjects query and implement corresponding tests - Updated projectRepository to include socialMedia in the allProjects query. - Enhanced GraphQL query in graphqlQueries to fetch socialMedia details. - Added new test cases to validate the presence of socialMedia in the allProjects response. --- src/repositories/projectRepository.ts | 3 ++- src/resolvers/projectResolver.test.ts | 26 ++++++++++++++++++++++++++ test/graphqlQueries.ts | 5 +++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/repositories/projectRepository.ts b/src/repositories/projectRepository.ts index 566b88593..aaafab2ab 100644 --- a/src/repositories/projectRepository.ts +++ b/src/repositories/projectRepository.ts @@ -103,7 +103,8 @@ export const filterProjectsQuery = (params: FilterProjectQueryInputParams) => { 'categories.isActive = :isActive', { isActive: true }, ) - .leftJoinAndSelect('categories.mainCategory', 'mainCategory'); + .leftJoinAndSelect('categories.mainCategory', 'mainCategory') + .leftJoinAndSelect('project.socialMedia', 'socialMedia'); const isFilterByQF = !!filters?.find(f => f === FilterField.ActiveQfRound) && activeQfRoundId; diff --git a/src/resolvers/projectResolver.test.ts b/src/resolvers/projectResolver.test.ts index 162408c1f..91d18ebdc 100644 --- a/src/resolvers/projectResolver.test.ts +++ b/src/resolvers/projectResolver.test.ts @@ -57,6 +57,11 @@ describe('projectBySlug test cases --->', projectBySlugTestCases); describe('projectById test cases --->', projectByIdTestCases); describe('projectSearch test cases --->', projectSearchTestCases); +describe( + 'allProjects socialMedia test cases --->', + allProjectsSocialMediaTestCases, +); + describe('updateProject test cases --->', updateProjectTestCases); describe( @@ -879,6 +884,27 @@ function projectByIdTestCases() { }); } +function allProjectsSocialMediaTestCases() { + it.only('should return projects with socialMedia when requested in GraphQL query', async () => { + const result = await axios.post(graphqlUrl, { + query: fetchMultiFilterAllProjectsQuery, + variables: { + limit: 10, + skip: 0, + }, + }); + + assert.isOk(result.data.data.allProjects); + const projects = result.data.data.allProjects.projects; + + if (projects.length > 0) { + // socialMedia field should be present when requested + assert.isDefined(projects[0].socialMedia); + assert.isArray(projects[0].socialMedia); + } + }); +} + function projectSearchTestCases() { it('should return projects with a typo in the end of searchTerm', async () => { const limit = 1; diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index a8336a948..08854c24b 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -834,6 +834,11 @@ export const fetchMultiFilterAllProjectsQuery = ` networkId chainType } + socialMedia { + id + type + link + } qfRounds { name isActive