From 478112863d782bd3602396b32461df117c0bf510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Mon, 18 Mar 2024 13:19:53 +0100 Subject: [PATCH] Add create assignment and get assignments endpoints in Exchange Oracle --- .../exchange-oracle/server/src/app.module.ts | 2 + .../server/src/common/enums/job.ts | 25 +++- ...n.ts => 1710760785464-initialMigration.ts} | 5 +- .../assignment/assignment.controller.ts | 41 ++++++ .../src/modules/assignment/assignment.dto.ts | 89 ++++++++++++ .../modules/assignment/assignment.entity.ts | 3 + .../assignment/assignment.interface.ts | 19 +++ .../modules/assignment/assignment.module.ts | 25 ++++ .../assignment/assignment.repository.ts | 80 ++++++++++ .../modules/assignment/assignment.service.ts | 137 ++++++++++++++++++ .../server/src/modules/job/job.repository.ts | 2 +- .../server/src/modules/job/job.service.ts | 2 +- 12 files changed, 418 insertions(+), 12 deletions(-) rename packages/apps/fortune/exchange-oracle/server/src/database/migrations/{1710439119068-initialMigration.ts => 1710760785464-initialMigration.ts} (96%) create mode 100644 packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.controller.ts create mode 100644 packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.dto.ts create mode 100644 packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.interface.ts create mode 100644 packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.module.ts create mode 100644 packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.repository.ts create mode 100644 packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.ts diff --git a/packages/apps/fortune/exchange-oracle/server/src/app.module.ts b/packages/apps/fortune/exchange-oracle/server/src/app.module.ts index a3b96aefad..b720e4efac 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/app.module.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/app.module.ts @@ -10,6 +10,7 @@ import { WebhookModule } from './modules/webhook/webhook.module'; import { JwtAuthGuard } from './common/guards/jwt.auth'; import { JwtHttpStrategy } from './common/guards/strategy'; import { Web3Module } from './modules/web3/web3.module'; +import { AssignmentModule } from './modules/assignment/assignment.module'; @Module({ providers: [ @@ -24,6 +25,7 @@ import { Web3Module } from './modules/web3/web3.module'; JwtHttpStrategy, ], imports: [ + AssignmentModule, JobModule, WebhookModule, Web3Module, diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/enums/job.ts b/packages/apps/fortune/exchange-oracle/server/src/common/enums/job.ts index 32ee5cfec9..03e1db93d1 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/common/enums/job.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/common/enums/job.ts @@ -4,6 +4,20 @@ export enum JobStatus { CANCELED = 'CANCELED', } +export enum JobSortField { + CHAIN_ID = 'chain_id', + JOB_TYPE = 'job_type', + REWARD_AMOUNT = 'reward_amount', + CREATED_AT = 'created_at', +} + +export enum JobFieldName { + JobDescription = 'job_description', + RewardAmount = 'reward_amount', + RewardToken = 'reward_token', + CreatedAt = 'created_at', +} + export enum AssignmentStatus { ACTIVE = 'ACTIVE', VALIDATION = 'VALIDATION', @@ -13,16 +27,11 @@ export enum AssignmentStatus { REJECTED = 'REJECTED', } -export enum JobSortField { +export enum AssignmentSortField { CHAIN_ID = 'chain_id', JOB_TYPE = 'job_type', + STATUS = 'status', REWARD_AMOUNT = 'reward_amount', CREATED_AT = 'created_at', -} - -export enum JobFieldName { - JobDescription = 'job_description', - RewardAmount = 'reward_amount', - RewardToken = 'reward_token', - CreatedAt = 'created_at', + EXPIRES_AT = 'expires_at', } diff --git a/packages/apps/fortune/exchange-oracle/server/src/database/migrations/1710439119068-initialMigration.ts b/packages/apps/fortune/exchange-oracle/server/src/database/migrations/1710760785464-initialMigration.ts similarity index 96% rename from packages/apps/fortune/exchange-oracle/server/src/database/migrations/1710439119068-initialMigration.ts rename to packages/apps/fortune/exchange-oracle/server/src/database/migrations/1710760785464-initialMigration.ts index 0437b33009..6a7e9558b7 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/database/migrations/1710439119068-initialMigration.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/database/migrations/1710760785464-initialMigration.ts @@ -1,8 +1,8 @@ import { NS } from '../../common/constant'; import { MigrationInterface, QueryRunner } from 'typeorm'; -export class InitialMigration1710439119068 implements MigrationInterface { - name = 'InitialMigration1710439119068'; +export class InitialMigration1710760785464 implements MigrationInterface { + name = 'InitialMigration1710760785464'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.createSchema(NS, true); @@ -49,6 +49,7 @@ export class InitialMigration1710439119068 implements MigrationInterface { "job_id" integer NOT NULL, "worker_address" character varying NOT NULL, "status" "hmt"."assignments_status_enum" NOT NULL, + "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_c54ca359535e0012b04dcbd80ee" PRIMARY KEY ("id") ) `); diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.controller.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.controller.ts new file mode 100644 index 0000000000..774d865859 --- /dev/null +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.controller.ts @@ -0,0 +1,41 @@ +import { + Body, + Controller, + Post, + UseGuards, + Request, + Get, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../common/guards/jwt.auth'; +import { AssignmentService } from './assignment.service'; +import { CreateAssignmentDto, GetAssignmentsDto } from './assignment.dto'; +import { RequestWithUser } from '../../common/types/jwt'; + +@ApiTags('Assignment') +@Controller('assignment') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class AssignmentController { + constructor(private readonly assignmentService: AssignmentService) {} + + @Post() + createAssignment( + @Request() req: RequestWithUser, + @Body() body: CreateAssignmentDto, + ): Promise { + return this.assignmentService.createAssignment(body, req.user); + } + + @Get() + getJobs( + @Request() req: RequestWithUser, + @Query() query: GetAssignmentsDto, + ): Promise { + return this.assignmentService.getAssignmentList( + query, + req.user.reputationNetwork, + ); + } +} diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.dto.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.dto.ts new file mode 100644 index 0000000000..501cab7627 --- /dev/null +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.dto.ts @@ -0,0 +1,89 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; +import { + AssignmentStatus, + AssignmentSortField, + JobSortField, + JobStatus, +} from '../../common/enums/job'; +import { PageOptionsDto } from '../../common/pagination/pagination.dto'; + +export class CreateAssignmentDto { + @ApiProperty({ name: 'chain_id' }) + @Type(() => Number) + @IsNumber() + chainId: number; + + @ApiProperty({ name: 'escrow_address' }) + @IsString() + escrowAddress: string; +} + +export class GetAssignmentsDto extends PageOptionsDto { + @ApiPropertyOptional({ name: 'chain_id' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + chainId: number; + + @ApiPropertyOptional({ name: 'job_type' }) + @IsOptional() + @IsString() + jobType: string; + + @ApiPropertyOptional({ name: 'escrow_address' }) + @IsOptional() + @IsString() + escrowAddress: string; + + @ApiPropertyOptional({ enum: AssignmentStatus }) + @IsEnum(AssignmentStatus) + @IsOptional() + status: AssignmentStatus; + + @ApiPropertyOptional({ + name: 'sort_field', + enum: JobSortField, + default: JobSortField.CREATED_AT, + }) + @IsOptional() + @IsEnum(AssignmentSortField) + sortField?: AssignmentSortField = AssignmentSortField.CREATED_AT; +} + +export class AssignmentDto { + assginmentId: number; + escrowAddress: string; + chainId: number; + jobType: string; + status: JobStatus; + url?: string; + rewardAmount: number; + rewardToken: string; + createdAt: number; + updatedAt?: number; + expiresAt: number; + + constructor( + assginmentId: number, + escrowAddress: string, + chainId: number, + jobType: string, + status: JobStatus, + rewardAmount: number, + rewardToken: string, + createdAt: number, + expiresAt: number, + ) { + this.assginmentId = assginmentId; + this.escrowAddress = escrowAddress; + this.chainId = chainId; + this.jobType = jobType; + this.status = status; + this.rewardAmount = rewardAmount; + this.rewardToken = rewardToken; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + } +} diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.entity.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.entity.ts index 4b375c6b4a..a59223fc1f 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.entity.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.entity.ts @@ -20,6 +20,9 @@ export class AssignmentEntity extends BaseEntity { }) public status: AssignmentStatus; + @Column({ type: 'timestamptz' }) + public expiresAt: Date; + @ManyToOne(() => JobEntity, (job) => job.assignments, { eager: true }) job: JobEntity; } diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.interface.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.interface.ts new file mode 100644 index 0000000000..7f27e32dc8 --- /dev/null +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.interface.ts @@ -0,0 +1,19 @@ +import { SortDirection } from '../../common/enums/collection'; +import { AssignmentSortField, AssignmentStatus } from '../../common/enums/job'; +import { AssignmentEntity } from './assignment.entity'; + +export interface AssignmentFilterData { + chainId?: number; + escrowAddress?: string; + status?: AssignmentStatus; + sortField?: AssignmentSortField; + sort?: SortDirection; + skip: number; + pageSize: number; + reputationNetwork: string; +} + +export interface ListResult { + entities: AssignmentEntity[]; + itemCount: number; +} diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.module.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.module.ts new file mode 100644 index 0000000000..4974ff3dfc --- /dev/null +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JobEntity } from '../job/job.entity'; +import { ConfigModule } from '@nestjs/config'; +import { AssignmentController } from './assignment.controller'; +import { AssignmentEntity } from './assignment.entity'; +import { AssignmentRepository } from './assignment.repository'; +import { AssignmentService } from './assignment.service'; +import { JobRepository } from '../job/job.repository'; +import { JobModule } from '../job/job.module'; +import { Web3Module } from '../web3/web3.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([AssignmentEntity]), + TypeOrmModule.forFeature([JobEntity]), + ConfigModule, + JobModule, + Web3Module, + ], + controllers: [AssignmentController], + providers: [AssignmentService, AssignmentRepository, JobRepository], + exports: [AssignmentService], +}) +export class AssignmentModule {} diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.repository.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.repository.ts new file mode 100644 index 0000000000..3de553f129 --- /dev/null +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.repository.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import { BaseRepository } from '../../database/base.repository'; +import { AssignmentEntity } from './assignment.entity'; +import { ChainId } from '@human-protocol/sdk'; +import { AssignmentFilterData, ListResult } from './assignment.interface'; +import { AssignmentSortField } from 'src/common/enums/job'; + +@Injectable() +export class AssignmentRepository extends BaseRepository { + constructor( + private dataSource: DataSource, + public readonly configService: ConfigService, + ) { + super(AssignmentEntity, dataSource); + } + + public async findOneByJobIdAndWorker( + jobId: ChainId, + workerAddress: string, + ): Promise { + return this.findOne({ + where: { + jobId, + workerAddress, + }, + }); + } + + public async countByJobId(jobId: ChainId): Promise { + return this.count({ + where: { + jobId, + }, + }); + } + + public async fetchFiltered(data: AssignmentFilterData): Promise { + const queryBuilder = await this.createQueryBuilder( + 'assignment', + ).leftJoinAndSelect('assignment.job', 'job', 'assignment.jobId = job.id'); + + if (data.sortField == AssignmentSortField.CHAIN_ID) + queryBuilder.orderBy(`job.${data.sortField}`, data.sort); + else if (data.sortField == AssignmentSortField.CREATED_AT) + queryBuilder.orderBy(`assignment.${data.sortField}`, data.sort); + else if (data.sortField == AssignmentSortField.STATUS) + queryBuilder.orderBy(`assignment.${data.sortField}`, data.sort); + else if (data.sortField == AssignmentSortField.EXPIRES_AT) + queryBuilder.orderBy(`assignment.${data.sortField}`, data.sort); + + if (data.chainId !== undefined) { + queryBuilder.andWhere('job.chainId = :chainId', { + chainId: data.chainId, + }); + } + if (data.escrowAddress) { + queryBuilder.andWhere('job.escrowAddress = :escrowAddress', { + escrowAddress: data.escrowAddress, + }); + } + if (data.status !== undefined) { + queryBuilder.andWhere('assignment.status = :status', { + status: data.status, + }); + } + + queryBuilder.andWhere('job.reputationNetwork = :reputationNetwork', { + reputationNetwork: data.reputationNetwork, + }); + + queryBuilder.offset(data.skip).limit(data.pageSize); + + const itemCount = await queryBuilder.getCount(); + const { entities } = await queryBuilder.getRawAndEntities(); + + return { entities, itemCount }; + } +} diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.ts new file mode 100644 index 0000000000..5fdd6dc3da --- /dev/null +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.ts @@ -0,0 +1,137 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { AssignmentStatus } from 'src/common/enums/job'; +import { JwtUser } from '../../common/types/jwt'; +import { JobRepository } from '../job/job.repository'; +import { + AssignmentDto, + CreateAssignmentDto, + GetAssignmentsDto, +} from './assignment.dto'; +import { AssignmentEntity } from './assignment.entity'; +import { AssignmentRepository } from './assignment.repository'; +import { PageDto } from 'src/common/pagination/pagination.dto'; +import { JOB_TYPE, TOKEN } from 'src/common/constant'; +import { JobService } from '../job/job.service'; +import { ConfigService } from '@nestjs/config'; +import { ConfigNames } from 'src/common/config'; +import { Escrow__factory } from '@human-protocol/core/typechain-types'; +import { Web3Service } from '../web3/web3.service'; + +@Injectable() +export class AssignmentService { + public readonly logger = new Logger(AssignmentService.name); + private storage: { + [key: string]: string[]; + } = {}; + + constructor( + public readonly assignmentRepository: AssignmentRepository, + public readonly jobRepository: JobRepository, + public readonly jobService: JobService, + public readonly web3Service: Web3Service, + public readonly configService: ConfigService, + ) {} + + public async createAssignment( + data: CreateAssignmentDto, + jwtUser: JwtUser, + ): Promise { + const jobEntity = await this.jobRepository.findOneByChainIdAndEscrowAddress( + data.chainId, + data.escrowAddress, + ); + + if (!jobEntity) { + this.logger.log('Job not found', AssignmentService.name); + throw new BadRequestException('Job not found'); + } else if (jobEntity.reputationNetwork !== jwtUser.reputationNetwork) { + this.logger.log( + 'Requested job is not in your reputation network', + AssignmentService.name, + ); + throw new BadRequestException( + 'Requested job is not in your reputation network', + ); + } + const assignmentEntity = + await this.assignmentRepository.findOneByJobIdAndWorker( + jobEntity.id, + jwtUser.address, + ); + + if (assignmentEntity) { + this.logger.log('Assignment already exists', AssignmentService.name); + throw new BadRequestException('Assignment already exists'); + } + + const currentAssignments = await this.assignmentRepository.countByJobId( + jobEntity.id, + ); + const manifest = await this.jobService.getManifest( + data.chainId, + data.escrowAddress, + ); + + if (currentAssignments >= manifest.submissionsRequired) { + this.logger.log('Fully assigned job', AssignmentService.name); + throw new BadRequestException('Fully assigned job'); + } + + const signer = this.web3Service.getSigner(data.chainId); + const escrow = Escrow__factory.connect(data.escrowAddress, signer); + const expirationDate = new Date(Number(await escrow.duration()) * 1000); + if (expirationDate < new Date()) { + this.logger.log('Expired escrow', AssignmentService.name); + throw new BadRequestException('Expired escrow'); + } + + const newAssignmentEntity = new AssignmentEntity(); + newAssignmentEntity.job = jobEntity; + newAssignmentEntity.workerAddress = jwtUser.address; + newAssignmentEntity.status = AssignmentStatus.ACTIVE; + newAssignmentEntity.expiresAt = expirationDate; + await this.assignmentRepository.createUnique(newAssignmentEntity); + } + + public async getAssignmentList( + data: GetAssignmentsDto, + reputationNetwork: string, + ): Promise> { + if (data.jobType && data.jobType !== JOB_TYPE) + return new PageDto(data.page!, data.pageSize!, 0, []); + + const { entities, itemCount } = + await this.assignmentRepository.fetchFiltered({ + ...data, + pageSize: data.pageSize!, + skip: data.skip!, + reputationNetwork, + }); + const assignments = await Promise.all( + entities.map(async (entity) => { + const manifest = await this.jobService.getManifest( + entity.job.chainId, + entity.job.escrowAddress, + ); + const assignment = new AssignmentDto( + entity.id, + entity.job.escrowAddress, + entity.job.chainId, + JOB_TYPE, + entity.job.status, + manifest.fundAmount / manifest.submissionsRequired, + TOKEN, + entity.createdAt.getTime(), + entity.expiresAt.getTime(), + ); + + if (entity.status === AssignmentStatus.ACTIVE) + assignment.url = `http://${this.configService.get(ConfigNames.HOST)}:${this.configService.get(ConfigNames.PORT)}`; + else assignment.updatedAt = entity.updatedAt.getTime(); + + return assignment; + }), + ); + return new PageDto(data.page!, data.pageSize!, itemCount, assignments); + } +} diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.repository.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.repository.ts index 363acd59d0..ab379d2403 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.repository.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.repository.ts @@ -62,7 +62,7 @@ export class JobRepository extends BaseRepository { reputationNetwork: data.reputationNetwork, }); - queryBuilder.skip(data.skip).take(data.pageSize); + queryBuilder.offset(data.skip).limit(data.pageSize); const itemCount = await queryBuilder.getCount(); const { entities } = await queryBuilder.getRawAndEntities(); diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts index 1867ef4e2d..27d4c81b63 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts @@ -257,7 +257,7 @@ export class JobService { ); } - private async getManifest( + public async getManifest( chainId: number, escrowAddress: string, ): Promise {