diff --git a/backend/package-lock.json b/backend/package-lock.json index 77de6c3b..9f691ac3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -240,7 +240,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2083,7 +2082,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", "integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "axios": "^1.3.1", @@ -2141,7 +2139,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2312,7 +2309,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.17.tgz", "integrity": "sha512-hLODw5Abp8OQgA+mUO4tHou4krKgDtUcM9j5Ihxncst9XeyxYBTt2bwZm4e4EQr5E352S4Fyy6V3iFx9ggxKAg==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.2", "iterare": "1.2.1", @@ -2372,7 +2368,6 @@ "integrity": "sha512-lD5mAYekTTurF3vDaa8C2OKPnjiz4tsfxIc5XlcSUzOhkwWf6Ay3HKvt6FmvuWQam6uIIHX52Clg+e6tAvf/cg==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2436,7 +2431,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.17.tgz", "integrity": "sha512-mAf4eOsSBsTOn/VbrUO1gsjW6dVh91qqXPMXun4dN8SnNjf7PTQagM9o8d6ab8ZBpNe6UdZftdrZoDetU+n4Qg==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -2731,7 +2725,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz", "integrity": "sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -3068,7 +3061,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -3203,7 +3195,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3374,7 +3365,6 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -4082,7 +4072,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4132,7 +4121,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4368,7 +4356,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -4713,7 +4700,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4943,7 +4929,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4991,15 +4976,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -5748,7 +5731,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5809,7 +5791,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6051,7 +6032,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7190,7 +7170,6 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -8927,7 +8906,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -9045,7 +9023,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -9155,7 +9132,6 @@ "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", - "peer": true, "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", @@ -9187,7 +9163,6 @@ "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz", "integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==", "license": "MIT", - "peer": true, "dependencies": { "get-caller-file": "^2.0.5", "pino": "^10.0.0", @@ -9392,7 +9367,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9619,8 +9593,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/require-directory": { "version": "2.1.1", @@ -9716,7 +9689,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -10416,7 +10388,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10776,7 +10747,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10937,7 +10907,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -11120,7 +11089,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11494,6 +11462,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -11512,6 +11481,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -11525,6 +11495,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -11539,6 +11510,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -11548,7 +11520,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/mime-db": { "version": "1.52.0", @@ -11556,6 +11529,7 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -11566,6 +11540,7 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -11579,6 +11554,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/backend/src/auth/auth.controller.spec.ts b/backend/src/auth/auth.controller.spec.ts index ab494eb0..a7859205 100644 --- a/backend/src/auth/auth.controller.spec.ts +++ b/backend/src/auth/auth.controller.spec.ts @@ -12,6 +12,7 @@ const mockAuthService = () => ({ (address: string) => `InsightArena:nonce:1234567890:randomhex:${address}`, ), verifyChallenge: jest.fn(), + verifyStellarSignature: jest.fn(), }); describe('AuthController', () => { @@ -93,4 +94,37 @@ describe('AuthController', () => { ); }); }); + + describe('verifyWallet', () => { + it('should return { verified: true } for a valid signature', () => { + const dto = { + stellar_address: 'G...Address', + challenge: 'InsightArena:dispute:123', + signature: 'a1b2c3d4', + }; + authService.verifyStellarSignature.mockReturnValue(true); + + const result = controller.verifyWallet(dto); + + expect(result).toEqual({ verified: true }); + expect(authService.verifyStellarSignature).toHaveBeenCalledWith( + dto.stellar_address, + dto.challenge, + dto.signature, + ); + }); + + it('should return { verified: false } for an invalid signature', () => { + const dto = { + stellar_address: 'G...Address', + challenge: 'InsightArena:dispute:123', + signature: 'wrong-signature', + }; + authService.verifyStellarSignature.mockReturnValue(false); + + const result = controller.verifyWallet(dto); + + expect(result).toEqual({ verified: false }); + }); + }); }); diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 239cfd8a..c81c83b5 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -3,7 +3,10 @@ import { Throttle } from '@nestjs/throttler'; import { AuthService } from './auth.service'; import { GenerateChallengeDto } from './dto/generate-challenge.dto'; import { VerifyChallengeDto } from './dto/verify-challenge.dto'; +import { VerifyWalletDto } from './dto/verify-wallet.dto'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +@ApiTags('Auth') @Throttle({ default: { limit: 10, ttl: 60000 } }) @Controller('auth') export class AuthController { @@ -26,4 +29,17 @@ export class AuthController { verifyChallengeDto.signed_challenge, ); } + + @Post('verify-wallet') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Verify wallet signature without session creation' }) + @ApiResponse({ status: 200, description: 'Verification result' }) + verifyWallet(@Body() dto: VerifyWalletDto) { + const verified = this.authService.verifyStellarSignature( + dto.stellar_address, + dto.challenge, + dto.signature, + ); + return { verified }; + } } diff --git a/backend/src/auth/dto/verify-wallet.dto.ts b/backend/src/auth/dto/verify-wallet.dto.ts new file mode 100644 index 00000000..346eb400 --- /dev/null +++ b/backend/src/auth/dto/verify-wallet.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class VerifyWalletDto { + @ApiProperty({ + example: 'GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3XNRBF7XN', + description: 'Stellar public key (G... address)', + }) + @IsString() + @IsNotEmpty() + stellar_address: string; + + @ApiProperty({ + example: 'InsightArena:dispute:123456789:abcdef', + description: 'The plaintext challenge that was signed', + }) + @IsString() + @IsNotEmpty() + challenge: string; + + @ApiProperty({ + example: 'a1b2c3d4...', + description: 'Hex-encoded signature', + }) + @IsString() + @IsNotEmpty() + signature: string; +} diff --git a/backend/src/competitions/competitions.controller.spec.ts b/backend/src/competitions/competitions.controller.spec.ts index 3f470cc3..3f29fbc3 100644 --- a/backend/src/competitions/competitions.controller.spec.ts +++ b/backend/src/competitions/competitions.controller.spec.ts @@ -6,7 +6,9 @@ import { Competition, CompetitionVisibility, } from './entities/competition.entity'; +import { CompetitionsService } from './competitions.service'; import { CreateCompetitionDto } from './dto/create-competition.dto'; +import { UserRankResponseDto } from './dto/user-rank-response.dto'; import { User } from '../users/entities/user.entity'; describe('CompetitionsController', () => { @@ -40,6 +42,7 @@ describe('CompetitionsController', () => { findAll: jest.fn(), findById: jest.fn(), list: jest.fn(), + getMyRank: jest.fn(), }, }, ], @@ -114,4 +117,37 @@ describe('CompetitionsController', () => { ); }); }); + + describe('getMyRank', () => { + it('should return user rank and stats', async () => { + const mockRankResponse: UserRankResponseDto = { + rank: 1, + score: 1000, + total_participants: 100, + percentile: 100, + }; + + const spy = jest + .spyOn(service, 'getMyRank') + .mockResolvedValue(mockRankResponse); + + const result = await controller.getMyRank( + 'comp-uuid-1', + mockUser as User, + ); + + expect(spy).toHaveBeenCalledWith('comp-uuid-1', mockUser.id); + expect(result).toEqual(mockRankResponse); + }); + + it('should throw NotFoundException if service throws it', async () => { + jest + .spyOn(service, 'getMyRank') + .mockRejectedValue(new NotFoundException('Not found')); + + await expect( + controller.getMyRank('nonexistent', mockUser as User), + ).rejects.toThrow(NotFoundException); + }); + }); }); diff --git a/backend/src/competitions/competitions.controller.ts b/backend/src/competitions/competitions.controller.ts index d17cbcab..c421b66e 100644 --- a/backend/src/competitions/competitions.controller.ts +++ b/backend/src/competitions/competitions.controller.ts @@ -27,6 +27,7 @@ import { ListParticipantsQueryDto, PaginatedParticipantsResponse, } from './dto/list-participants.dto'; +import { UserRankResponseDto } from './dto/user-rank-response.dto'; import { Competition } from './entities/competition.entity'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { Public } from '../common/decorators/public.decorator'; @@ -95,4 +96,23 @@ export class CompetitionsController { ): Promise { return this.competitionsService.getParticipants(id, query); } + + @Get(':id/my-rank') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get current user rank in a competition' }) + @ApiResponse({ + status: 200, + description: 'User rank, score, and percentile', + type: UserRankResponseDto, + }) + @ApiResponse({ + status: 404, + description: 'Competition or participant not found', + }) + async getMyRank( + @Param('id') id: string, + @CurrentUser() user: User, + ): Promise { + return this.competitionsService.getMyRank(id, user.id); + } } diff --git a/backend/src/competitions/competitions.service.spec.ts b/backend/src/competitions/competitions.service.spec.ts index a6dedc04..34e961b5 100644 --- a/backend/src/competitions/competitions.service.spec.ts +++ b/backend/src/competitions/competitions.service.spec.ts @@ -41,6 +41,8 @@ describe('CompetitionsService', () => { const mockParticipantsRepository = { createQueryBuilder: jest.fn(), + findOne: jest.fn(), + count: jest.fn(), }; beforeEach(async () => { @@ -227,4 +229,77 @@ describe('CompetitionsService', () => { ).rejects.toThrow(NotFoundException); }); }); + + describe('getMyRank', () => { + it('should return user rank and percentile', async () => { + mockRepository.findOne.mockResolvedValue(mockCompetition); + mockParticipantsRepository.findOne.mockResolvedValue({ + id: 'part-1', + user_id: 'user-uuid-1', + score: 100, + joined_at: new Date('2024-01-01'), + }); + + const qbMock = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(4), // 4 people ahead + }; + mockParticipantsRepository.createQueryBuilder.mockReturnValue(qbMock); + mockParticipantsRepository.count.mockResolvedValue(10); // 10 total + + const result = await service.getMyRank('comp-uuid-1', 'user-uuid-1'); + + expect(result).toEqual({ + rank: 5, + score: 100, + total_participants: 10, + percentile: 60, // (1 - (5-1)/10) * 100 = 60 + }); + }); + + it('should throw NotFoundException if user is not a participant', async () => { + mockRepository.findOne.mockResolvedValue(mockCompetition); + mockParticipantsRepository.findOne.mockResolvedValue(null); + + await expect( + service.getMyRank('comp-uuid-1', 'user-uuid-1'), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException if competition does not exist', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect( + service.getMyRank('non-existent', 'user-uuid-1'), + ).rejects.toThrow(NotFoundException); + }); + + it('should use cache on subsequent calls', async () => { + mockRepository.findOne.mockResolvedValue(mockCompetition); + mockParticipantsRepository.findOne.mockResolvedValue({ + id: 'part-1', + user_id: 'user-uuid-1', + score: 100, + joined_at: new Date('2024-01-01'), + }); + + const qbMock = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(0), + }; + mockParticipantsRepository.createQueryBuilder.mockReturnValue(qbMock); + mockParticipantsRepository.count.mockResolvedValue(1); + + // First call + await service.getMyRank('comp-uuid-1', 'user-uuid-1'); + // Second call should hit cache + await service.getMyRank('comp-uuid-1', 'user-uuid-1'); + + expect( + mockParticipantsRepository.createQueryBuilder, + ).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/backend/src/competitions/competitions.service.ts b/backend/src/competitions/competitions.service.ts index 6ccb12de..1bb0127e 100644 --- a/backend/src/competitions/competitions.service.ts +++ b/backend/src/competitions/competitions.service.ts @@ -18,9 +18,16 @@ import { PaginatedParticipantsResponse, } from './dto/list-participants.dto'; import { User } from '../users/entities/user.entity'; +import { UserRankResponseDto } from './dto/user-rank-response.dto'; @Injectable() export class CompetitionsService { + private rankCache = new Map< + string, + { data: UserRankResponseDto; timestamp: number } + >(); + private readonly RANK_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + constructor( @InjectRepository(Competition) private readonly competitionsRepository: Repository, @@ -194,4 +201,69 @@ export class CompetitionsService { relations: ['creator'], }); } + + async getMyRank( + competitionId: string, + userId: string, + ): Promise { + const cacheKey = `${competitionId}:${userId}`; + const cached = this.rankCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < this.RANK_CACHE_TTL_MS) { + return cached.data; + } + + const competition = await this.competitionsRepository.findOne({ + where: { id: competitionId }, + }); + + if (!competition) { + throw new NotFoundException( + `Competition with ID "${competitionId}" not found`, + ); + } + + const participant = await this.participantsRepository.findOne({ + where: { competition_id: competitionId, user_id: userId }, + }); + + if (!participant) { + throw new NotFoundException( + `User is not a participant in competition "${competitionId}"`, + ); + } + + // Calculate rank: count participants with higher score, + // or same score but joined earlier. + const rank = + (await this.participantsRepository + .createQueryBuilder('p') + .where('p.competition_id = :competitionId', { competitionId }) + .andWhere( + '(p.score > :score OR (p.score = :score AND p.joined_at < :joinedAt))', + { + score: participant.score, + joinedAt: participant.joined_at, + }, + ) + .getCount()) + 1; + + const total_participants = await this.participantsRepository.count({ + where: { competition_id: competitionId }, + }); + + const percentile = + total_participants > 0 + ? Math.round((1 - (rank - 1) / total_participants) * 10000) / 100 + : 100; + + const result: UserRankResponseDto = { + rank, + score: participant.score, + total_participants, + percentile, + }; + + this.rankCache.set(cacheKey, { data: result, timestamp: Date.now() }); + return result; + } } diff --git a/backend/src/competitions/dto/user-rank-response.dto.ts b/backend/src/competitions/dto/user-rank-response.dto.ts new file mode 100644 index 00000000..b7b8485a --- /dev/null +++ b/backend/src/competitions/dto/user-rank-response.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UserRankResponseDto { + @ApiProperty({ example: 42 }) + rank: number; + + @ApiProperty({ example: 850 }) + score: number; + + @ApiProperty({ example: 1200 }) + total_participants: number; + + @ApiProperty({ example: 96.5, description: 'User percentile (0-100)' }) + percentile: number; +} diff --git a/backend/src/search/search.controller.ts b/backend/src/search/search.controller.ts index 429a98a9..7a8208b3 100644 --- a/backend/src/search/search.controller.ts +++ b/backend/src/search/search.controller.ts @@ -7,7 +7,10 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Public } from '../common/decorators/public.decorator'; -import { GlobalSearchDto, GlobalSearchResponseDto } from './dto/global-search.dto'; +import { + GlobalSearchDto, + GlobalSearchResponseDto, +} from './dto/global-search.dto'; import { SearchService } from './search.service'; @ApiTags('Search')