diff --git a/migrations/1634645086422-AuctionBid.ts b/migrations/1634645086422-AuctionBid.ts new file mode 100644 index 00000000..706c26bb --- /dev/null +++ b/migrations/1634645086422-AuctionBid.ts @@ -0,0 +1,14 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class AuctionBid1634645086422 implements MigrationInterface { + name = 'AuctionBid1634645086422' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "universe-backend"."auction_bid" ("id" SERIAL NOT NULL, "userId" integer NOT NULL, "auctionId" integer NOT NULL, "amount" numeric NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_022e4f8fe9416b6f1e13c55cdfb" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "universe-backend"."auction_bid"`); + } + +} diff --git a/src/modules/auction/auction.module.ts b/src/modules/auction/auction.module.ts index ebb9ab43..ee070df2 100644 --- a/src/modules/auction/auction.module.ts +++ b/src/modules/auction/auction.module.ts @@ -7,6 +7,7 @@ import { MulterConfigService } from '../multer/multer.service'; import { Nft } from '../nft/domain/nft.entity'; import { User } from '../users/user.entity'; import { Auction } from './domain/auction.entity'; +import { AuctionBid } from './domain/auction.bid.entity'; import { RewardTierNft } from './domain/reward-tier-nft.entity'; import { RewardTier } from './domain/reward-tier.entity'; import { AuctionController } from './entrypoints/auction.controller'; @@ -22,7 +23,7 @@ import { NftCollection } from '../nft/domain/collection.entity'; MulterModule.registerAsync({ useClass: MulterConfigService, }), - TypeOrmModule.forFeature([User, Auction, RewardTier, RewardTierNft, Nft, NftCollection]), + TypeOrmModule.forFeature([User, Auction, RewardTier, RewardTierNft, Nft, NftCollection, AuctionBid]), FileSystemModule, UsersModule, ], diff --git a/src/modules/auction/domain/auction.bid.entity.ts b/src/modules/auction/domain/auction.bid.entity.ts new file mode 100644 index 00000000..7840f452 --- /dev/null +++ b/src/modules/auction/domain/auction.bid.entity.ts @@ -0,0 +1,24 @@ +import { Exclude } from 'class-transformer'; +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity({ + schema: 'universe-backend', +}) +export class AuctionBid { + @PrimaryGeneratedColumn() + id: number; + + @Column() + @Exclude() + userId: number; + + @Column() + @Exclude() + auctionId: number; + + @Column({ type: 'decimal' }) + amount: number; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/modules/auction/domain/auction.entity.ts b/src/modules/auction/domain/auction.entity.ts index 0de58de5..40455036 100644 --- a/src/modules/auction/domain/auction.entity.ts +++ b/src/modules/auction/domain/auction.entity.ts @@ -52,27 +52,21 @@ export class Auction { backgroundImageBlur: boolean; @Column({ default: false }) - @Exclude() onChain: boolean; @Column({ default: true }) - @Exclude() initialised: boolean; @Column({ default: false }) - @Exclude() depositedNfts: boolean; @Column({ default: false }) - @Exclude() canceled: boolean; @Column({ default: false }) - @Exclude() finalised: boolean; @Column({ nullable: true }) - @Exclude() onChainId: number; @Column({ nullable: true }) diff --git a/src/modules/auction/entrypoints/auction.controller.ts b/src/modules/auction/entrypoints/auction.controller.ts index b3c3c0f2..1dd1f313 100644 --- a/src/modules/auction/entrypoints/auction.controller.ts +++ b/src/modules/auction/entrypoints/auction.controller.ts @@ -22,6 +22,7 @@ import { GetAuctionPageParams, GetMyAuctionsQuery, GetMyAuctionsResponse, + PlaceBidBody, UpdateAuctionExtraBody, UpdateRewardTierBody, UpdateRewardTierExtraBody, @@ -284,6 +285,18 @@ export class AuctionController { return await this.auctionService.listAuctionsByStatus(status, page, limit); } + @Post('auction/placeBid') + @UseGuards(JwtAuthGuard) + async placeAuctionBid(@Req() req, @Body() placeBidBody: PlaceBidBody) { + return await this.auctionService.placeAuctionBid(req.user.sub, placeBidBody); + } + + @Get('pages/my-bids') + @UseGuards(JwtAuthGuard) + async getUserBids(@Req() req) { + return await this.auctionService.getUserBids(req.user.sub); + } + //Todo: add tier info @Get('auction/{:id}') @UseGuards(JwtAuthGuard) diff --git a/src/modules/auction/entrypoints/dto.ts b/src/modules/auction/entrypoints/dto.ts index 89a86c79..086c1322 100644 --- a/src/modules/auction/entrypoints/dto.ts +++ b/src/modules/auction/entrypoints/dto.ts @@ -660,3 +660,19 @@ export class GetAuctionPageParams { @IsString() auctionName: string; } + +export class PlaceBidBody { + @ApiProperty({ + example: 1, + description: 'The id of the auction to which the user is bidding', + }) + @IsNumber() + auctionId: number; + + @ApiProperty({ + example: '0.1', + description: 'Amount of crypto the user is bidding', + }) + @IsNumber() + amount: number; +} diff --git a/src/modules/auction/service-layer/auction.service.ts b/src/modules/auction/service-layer/auction.service.ts index 91debdbf..07a91116 100644 --- a/src/modules/auction/service-layer/auction.service.ts +++ b/src/modules/auction/service-layer/auction.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { getManager, In, LessThan, MoreThan, Repository, Transaction, TransactionRepository } from 'typeorm'; +import { getManager, In, LessThan, MoreThan, Not, Repository, Transaction, TransactionRepository } from 'typeorm'; import { RewardTier } from '../domain/reward-tier.entity'; import { RewardTierNft } from '../domain/reward-tier-nft.entity'; import { Auction } from '../domain/auction.entity'; @@ -13,6 +13,7 @@ import { EditAuctionBody, UpdateRewardTierBody, DepositNftsBody, + PlaceBidBody, } from '../entrypoints/dto'; import { Nft } from 'src/modules/nft/domain/nft.entity'; import { AuctionNotFoundException } from './exceptions/AuctionNotFoundException'; @@ -24,6 +25,8 @@ import { UsersService } from '../../users/users.service'; import { classToPlain } from 'class-transformer'; import { UploadResult } from 'src/modules/file-storage/model/UploadResult'; import { NftCollection } from 'src/modules/nft/domain/collection.entity'; +import { AuctionBid } from '../domain/auction.bid.entity'; +import { User } from 'src/modules/users/user.entity'; @Injectable() export class AuctionService { @@ -39,6 +42,8 @@ export class AuctionService { private nftCollectionRepository: Repository, @InjectRepository(Nft) private nftRepository: Repository, + @InjectRepository(AuctionBid) + private auctionBidRepository: Repository, private s3Service: S3Service, private fileSystemService: FileSystemService, private readonly config: AppConfig, @@ -46,30 +51,28 @@ export class AuctionService { async getAuctionPage(username: string, auctionName: string) { const artist = await this.usersService.getByUsername(username); - const link = `universe.xyz/${username}/${auctionName}`; //TODO: add a check if the auction has started - const auction = await this.auctionRepository.findOne({ link: link }); + const auction = await this.auctionRepository.findOne({ link: auctionName }); if (!auction) { throw new AuctionNotFoundException(); } - // TODO: Add collection info for each nft + // TODO: Add collection info for each nft(Maybe won't be needed) const rewardTiers = await this.rewardTierRepository.find({ where: { auctionId: auction.id } }); const rewardTierNfts = await this.rewardTierNftRepository.find({ where: { rewardTierId: In(rewardTiers.map((rewardTier) => rewardTier.id)) }, }); const nftIds = rewardTierNfts.map((rewardTierNft) => rewardTierNft.nftId); - // console.log(nftIds); const nfts = await this.nftRepository.find({ where: { id: In(nftIds) } }); const nftCollectionids = nfts.map((nft) => nft.collectionId); const collections = await this.nftCollectionRepository.find({ id: In(nftCollectionids) }); const idNftMap = nfts.reduce((acc, nft) => ({ ...acc, [nft.id]: nft }), {} as Record); - // console.log(idNftMap); + const rewardTierNftsMap = rewardTierNfts.reduce( (acc, rewardTierNft) => ({ ...acc, @@ -78,6 +81,22 @@ export class AuctionService { {} as Record, ); + //TODO: Add pagination to this query to reduce the load on the BE + // https://github.com/UniverseXYZ/UniverseApp-Backend/issues/100 + const now = new Date().toISOString(); + const moreActiveAuctions = await this.auctionRepository.find({ + where: { userId: artist.id, id: Not(auction.id), startDate: LessThan(now), endDate: MoreThan(now) }, + }); + + const bids = await this.auctionBidRepository + .createQueryBuilder('bid') + .leftJoinAndMapOne('bid.user', User, 'bidder', 'bidder.id = bid.userId') + .where({ auctionId: auction.id }) + .orderBy('bid.amount', 'DESC') + .getMany(); + + bids.sort((a, b) => b.amount - a.amount); + return { auction: classToPlain(auction), artist: classToPlain(artist), @@ -86,8 +105,8 @@ export class AuctionService { ...classToPlain(rewardTier), nfts: rewardTierNftsMap[rewardTier.id].map((nft) => classToPlain(nft)), })), - bids: [], - moreActiveAuctions: [], + moreActiveAuctions: moreActiveAuctions.map((a) => classToPlain(a)), + bidders: bids, }; } @@ -504,7 +523,11 @@ export class AuctionService { async getMyPastAuctionsPage(userId: number, limit: number, offset: number) { const user = await this.usersService.getById(userId, true); const { count, auctions } = await this.getMyPastAuctions(userId, limit, offset); - const formattedAuctions = await this.formatMyAuctions(auctions); + const auctionsWithTiers = await this.formatMyAuctions(auctions); + let auctionsWithBids = []; + if (auctionsWithTiers.length) { + auctionsWithBids = await this.attachBidsInfo(auctionsWithTiers); + } return { pagination: { @@ -512,14 +535,19 @@ export class AuctionService { offset, limit, }, - auctions: formattedAuctions, + auctions: auctionsWithBids, }; } async getMyActiveAuctionsPage(userId: number, limit: number, offset: number) { const user = await this.usersService.getById(userId, true); const { count, auctions } = await this.getMyActiveAuctions(userId, limit, offset); - const formattedAuctions = await this.formatMyAuctions(auctions); + const auctionsWithTiers = await this.formatMyAuctions(auctions); + + let auctionsWithBids = []; + if (auctionsWithTiers.length) { + auctionsWithBids = await this.attachBidsInfo(auctionsWithTiers); + } return { pagination: { @@ -527,10 +555,47 @@ export class AuctionService { offset, limit, }, - auctions: formattedAuctions, + auctions: auctionsWithBids, }; } + private async attachBidsInfo(auctions: Auction[]) { + const auctionIds = auctions.map((auction) => auction.id); + const bidsQuery = await this.auctionBidRepository + .createQueryBuilder('bid') + .select([ + 'bid.auctionId', + 'MIN(bid.amount) as min', + 'MAX(bid.amount) as max', + 'SUM(bid.amount) as totalBidsAmount', + 'COUNT(*) as bidCount', + ]) + .groupBy('bid.auctionId') + .where('bid.auctionId IN (:...auctionIds)', { auctionIds: auctionIds }) + .getRawMany(); + + return auctions.map((auction) => { + const bid = bidsQuery.find((bid) => bid['bid_auctionId'] === auction.id); + const bids = { + bidsCount: 0, + highestBid: 0, + lowestBid: 0, + totalBids: 0, + }; + if (bid) { + bids.bidsCount = +bid['bidcount']; + bids.highestBid = +bid['max']; + bids.lowestBid = +bid['min']; + bids.totalBids = +bid['totalbidsamount']; + } else { + } + return { + ...auction, + bids, + }; + }); + } + private async formatMyAuctions(auctions: Auction[]) { const auctionIds = auctions.map((auction) => auction.id); const rewardTiers = await this.rewardTierRepository.find({ where: { auctionId: In(auctionIds) } }); @@ -729,6 +794,128 @@ export class AuctionService { }; } + public async getUserBids(userId: number) { + //TODO: Add Pagination as this request can get quite computation heavy + + // User should have only one bid per auction -> if user places multiple bids their amount should be accumulated into a single bid (That's how smart contract works) + const bids = await this.auctionBidRepository.find({ where: { userId: userId }, order: { createdAt: 'DESC' } }); + const auctionIds = bids.map((bid) => bid.auctionId); + + const [auctions, rewardTiers, bidsQuery] = await Promise.all([ + this.auctionRepository + .createQueryBuilder('auction') + .leftJoinAndMapOne('auction.creator', User, 'creator', 'creator.id = auction.userId') + .where('auction.id IN (:...auctionIds)', { auctionIds: auctionIds }) + .getMany(), + this.rewardTierRepository.find({ where: { auctionId: In(auctionIds) } }), + this.auctionBidRepository + .createQueryBuilder('bid') + .select(['bid.auctionId', 'MIN(bid.amount) as min', 'MAX(bid.amount) as max', 'COUNT(*) as bidCount']) + .groupBy('bid.auctionId') + .where('bid.auctionId IN (:...auctionIds)', { auctionIds: auctionIds }) + .getRawMany(), + ]); + + const rewardTiersNfts = await this.rewardTierNftRepository.find({ + where: { rewardTierId: In(rewardTiers.map((nft) => nft.id)) }, + }); + + const rewardTierNftsByRewardTierId = rewardTiersNfts.reduce((acc, rewardTierNft) => { + const group = acc[rewardTierNft.rewardTierId] || []; + group.push(rewardTierNft); + acc[rewardTierNft.rewardTierId] = group; + return acc; + }, {}); + + const rewardTiersByAuctionId = rewardTiers.reduce((acc, tier) => { + const group = acc[tier.auctionId] || []; + group.push(tier); + acc[tier.auctionId] = group; + return acc; + }, {}); + + const auctionsById = auctions.reduce((acc, auction) => { + acc[auction.id] = auction; + return acc; + }, {}); + + const mappedBids = bids.map((bid) => { + const bidResult = bidsQuery.find((b) => b['bid_auctionId'] === bid.auctionId); + const auctionBidsCount = +bidResult['bidcount']; + const highestBid = +bidResult['max']; + const lowestBid = +bidResult['min']; + const tiers = rewardTiersByAuctionId[bid.auctionId]; + + // If auction has 5 winning slots but received only one bid -> numberOfWinners should be 1) + const totalAuctionNumberOfWinners = tiers.reduce((acc, tier) => (acc += tier.numberOfWinners), 0); + const numberOfWinners = Math.min(totalAuctionNumberOfWinners, auctionBidsCount); + + const tierNftIds = Object.keys(rewardTierNftsByRewardTierId).map((key) => +key); + const nftsBySlot = rewardTiersNfts + .filter((tierNft) => tierNftIds.includes(tierNft.rewardTierId)) + .reduce((acc, item) => { + const group = acc[item.slot] || []; + group.push(item); + acc[item.slot] = group; + return acc; + }, {}); + + let maxNfts = Number.MIN_SAFE_INTEGER; + let minNfts = Number.MAX_SAFE_INTEGER; + + Object.keys(nftsBySlot).forEach((slot) => { + if (nftsBySlot[slot].length > maxNfts) { + maxNfts = nftsBySlot[slot].length; + } + if (nftsBySlot[slot].length < minNfts) { + minNfts = nftsBySlot[slot].length; + } + }); + + return { + bid: classToPlain(bid), + auction: classToPlain(auctionsById[bid.auctionId]), + highestBid, + lowestBid, + numberOfWinners, + maxNfts, + minNfts, + }; + }); + return { bids: mappedBids, pagination: {} }; + } + + public async placeAuctionBid(userId: number, placeBidBody: PlaceBidBody) { + //TODO: This is a temporary endpoint until the scraper functionality is finished + const auction = await this.auctionRepository.findOne(placeBidBody.auctionId); + + if (!auction) { + throw new AuctionNotFoundException(); + } + + const bidder = await this.usersService.getById(userId); + + const bid = await this.auctionBidRepository.findOne({ + where: { userId, auctionId: placeBidBody.auctionId }, + }); + + if (bid) { + await this.auctionBidRepository.update(bid.id, { + amount: +bid.amount + +placeBidBody.amount, + }); + } else { + await this.auctionBidRepository.save({ + userId: userId, + amount: placeBidBody.amount, + auctionId: placeBidBody.auctionId, + }); + } + const response = { ...placeBidBody, user: bidder }; + return { + bid: response, + }; + } + private setPagination(query, page: number, limit: number) { if (limit === 0 || page === 0) return; diff --git a/src/modules/database/database.providers.ts b/src/modules/database/database.providers.ts index 55343f08..1a291032 100644 --- a/src/modules/database/database.providers.ts +++ b/src/modules/database/database.providers.ts @@ -13,6 +13,7 @@ import { DeployCollectionEvent } from '../ethEventsScraper/domain/deploy-collect import { MintingCollection } from '../nft/domain/minting-collection.entity'; import { LoginChallenge } from '../auth/model/login-challenge.entity'; import { MintingNft } from '../nft/domain/minting-nft.entity'; +import { AuctionBid } from '../auction/domain/auction.bid.entity'; import { MonitoredNfts } from '../nft/domain/monitored-nfts'; // TODO: Add db entities here @@ -29,6 +30,7 @@ const entities = [ MintingCollection, LoginChallenge, MintingNft, + AuctionBid, MonitoredNfts, ];