diff --git a/backend/package-lock.json b/backend/package-lock.json index 9f691ac3..77de6c3b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -240,6 +240,7 @@ "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", @@ -2082,6 +2083,7 @@ "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", @@ -2139,6 +2141,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2309,6 +2312,7 @@ "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", @@ -2368,6 +2372,7 @@ "integrity": "sha512-lD5mAYekTTurF3vDaa8C2OKPnjiz4tsfxIc5XlcSUzOhkwWf6Ay3HKvt6FmvuWQam6uIIHX52Clg+e6tAvf/cg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2431,6 +2436,7 @@ "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", @@ -2725,6 +2731,7 @@ "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", @@ -3061,6 +3068,7 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -3195,6 +3203,7 @@ "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" } @@ -3365,6 +3374,7 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -4072,6 +4082,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4121,6 +4132,7 @@ "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", @@ -4356,6 +4368,7 @@ "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", @@ -4700,6 +4713,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4929,6 +4943,7 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4976,13 +4991,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "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", @@ -5731,6 +5748,7 @@ "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", @@ -5791,6 +5809,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6032,6 +6051,7 @@ "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", @@ -7170,6 +7190,7 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -8906,6 +8927,7 @@ "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", @@ -9023,6 +9045,7 @@ "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", @@ -9132,6 +9155,7 @@ "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", @@ -9163,6 +9187,7 @@ "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", @@ -9367,6 +9392,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9593,7 +9619,8 @@ "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" + "license": "Apache-2.0", + "peer": true }, "node_modules/require-directory": { "version": "2.1.1", @@ -9689,6 +9716,7 @@ "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" } @@ -10388,6 +10416,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10747,6 +10776,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10907,6 +10937,7 @@ "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", @@ -11089,6 +11120,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11462,7 +11494,6 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -11481,7 +11512,6 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -11495,7 +11525,6 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -11510,7 +11539,6 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -11520,8 +11548,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/webpack/node_modules/mime-db": { "version": "1.52.0", @@ -11529,7 +11556,6 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -11540,7 +11566,6 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -11554,7 +11579,6 @@ "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/achievements/achievements.controller.ts b/backend/src/achievements/achievements.controller.ts new file mode 100644 index 00000000..0cd8a3bf --- /dev/null +++ b/backend/src/achievements/achievements.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Param, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from '../common/decorators/public.decorator'; +import { AchievementsService } from './achievements.service'; +import { AchievementResponseDto } from './dto/achievement-response.dto'; + +@ApiTags('Achievements') +@Controller('users/:address/achievements') +export class AchievementsController { + constructor(private readonly achievementsService: AchievementsService) {} + + @Get() + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get user achievements and badges' }) + @ApiResponse({ + status: 200, + description: 'List of achievements with unlock status', + type: [AchievementResponseDto], + }) + @ApiResponse({ status: 404, description: 'User not found' }) + async getUserAchievements( + @Param('address') address: string, + ): Promise { + return this.achievementsService.getUserAchievements(address); + } +} diff --git a/backend/src/achievements/achievements.module.ts b/backend/src/achievements/achievements.module.ts new file mode 100644 index 00000000..3d23d946 --- /dev/null +++ b/backend/src/achievements/achievements.module.ts @@ -0,0 +1,21 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Achievement } from './entities/achievement.entity'; +import { UserAchievement } from './entities/user-achievement.entity'; +import { AchievementsService } from './achievements.service'; +import { AchievementsController } from './achievements.controller'; +import { User } from '../users/entities/user.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Achievement, UserAchievement, User])], + providers: [AchievementsService], + controllers: [AchievementsController], + exports: [AchievementsService], +}) +export class AchievementsModule implements OnModuleInit { + constructor(private readonly achievementsService: AchievementsService) {} + + async onModuleInit(): Promise { + await this.achievementsService.initializeAchievements(); + } +} diff --git a/backend/src/achievements/achievements.service.spec.ts b/backend/src/achievements/achievements.service.spec.ts new file mode 100644 index 00000000..ff48b1f3 --- /dev/null +++ b/backend/src/achievements/achievements.service.spec.ts @@ -0,0 +1,112 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AchievementsService } from './achievements.service'; +import { Achievement, AchievementType } from './entities/achievement.entity'; +import { UserAchievement } from './entities/user-achievement.entity'; +import { User } from '../users/entities/user.entity'; + +describe('AchievementsService', () => { + let service: AchievementsService; + let achievementsRepository: jest.Mocked>; + let userAchievementsRepository: jest.Mocked>; + let usersRepository: jest.Mocked>; + + const mockUser = { + id: 'user-1', + stellar_address: 'GABC123', + total_predictions: 10, + correct_predictions: 9, + total_staked_stroops: '5000000', + reputation_score: 600, + } as User; + + beforeEach(async () => { + achievementsRepository = { + count: jest.fn().mockResolvedValue(0), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + } as any; + + userAchievementsRepository = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + } as any; + + usersRepository = { + findOne: jest.fn().mockResolvedValue(mockUser), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AchievementsService, + { + provide: getRepositoryToken(Achievement), + useValue: achievementsRepository, + }, + { + provide: getRepositoryToken(UserAchievement), + useValue: userAchievementsRepository, + }, + { + provide: getRepositoryToken(User), + useValue: usersRepository, + }, + ], + }).compile(); + + service = module.get(AchievementsService); + }); + + it('should initialize achievements on first call', async () => { + await service.initializeAchievements(); + expect(achievementsRepository.save).toHaveBeenCalled(); + }); + + it('should check and unlock achievements for user', async () => { + const mockAchievement = { + id: 'ach-1', + type: AchievementType.FIRST_PREDICTION, + title: 'First Step', + } as Achievement; + + achievementsRepository.findOne.mockResolvedValue(mockAchievement); + userAchievementsRepository.findOne.mockResolvedValue(null); + + await service.checkAndUnlockAchievements(mockUser); + + expect(userAchievementsRepository.save).toHaveBeenCalled(); + }); + + it('should get user achievements', async () => { + const mockAchievements = [ + { + id: 'ach-1', + type: AchievementType.FIRST_PREDICTION, + title: 'First Step', + description: 'Make your first prediction', + icon_url: null, + reward_points: 10, + }, + ] as Achievement[]; + + const mockUserAchievements = [ + { + achievement: mockAchievements[0], + is_unlocked: true, + unlocked_at: new Date(), + }, + ] as UserAchievement[]; + + usersRepository.findOne.mockResolvedValue(mockUser); + userAchievementsRepository.find.mockResolvedValue(mockUserAchievements); + achievementsRepository.find.mockResolvedValue(mockAchievements); + + const result = await service.getUserAchievements(mockUser.stellar_address); + + expect(result).toHaveLength(1); + expect(result[0].is_unlocked).toBe(true); + }); +}); diff --git a/backend/src/achievements/achievements.service.ts b/backend/src/achievements/achievements.service.ts new file mode 100644 index 00000000..58792e09 --- /dev/null +++ b/backend/src/achievements/achievements.service.ts @@ -0,0 +1,215 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Achievement, AchievementType } from './entities/achievement.entity'; +import { UserAchievement } from './entities/user-achievement.entity'; +import { User } from '../users/entities/user.entity'; +import { AchievementResponseDto } from './dto/achievement-response.dto'; + +@Injectable() +export class AchievementsService { + private readonly logger = new Logger(AchievementsService.name); + + constructor( + @InjectRepository(Achievement) + private readonly achievementsRepository: Repository, + @InjectRepository(UserAchievement) + private readonly userAchievementsRepository: Repository, + @InjectRepository(User) + private readonly usersRepository: Repository, + ) {} + + async initializeAchievements(): Promise { + const count = await this.achievementsRepository.count(); + if (count > 0) return; + + const achievements = [ + { + type: AchievementType.FIRST_PREDICTION, + title: 'First Step', + description: 'Make your first prediction', + reward_points: 10, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_10, + title: 'Rising Star', + description: 'Get 10 correct predictions', + reward_points: 50, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_50, + title: 'Seasoned Predictor', + description: 'Get 50 correct predictions', + reward_points: 150, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_100, + title: 'Master Predictor', + description: 'Get 100 correct predictions', + reward_points: 300, + }, + { + type: AchievementType.ACCURACY_75, + title: 'Accurate Mind', + description: 'Achieve 75% prediction accuracy', + reward_points: 100, + }, + { + type: AchievementType.ACCURACY_90, + title: 'Legendary Accuracy', + description: 'Achieve 90% prediction accuracy', + reward_points: 250, + }, + { + type: AchievementType.TOTAL_STAKED_1M, + title: 'High Roller', + description: 'Stake 1,000,000 stroops total', + reward_points: 75, + }, + { + type: AchievementType.TOTAL_STAKED_10M, + title: 'Whale Predictor', + description: 'Stake 10,000,000 stroops total', + reward_points: 200, + }, + { + type: AchievementType.REPUTATION_500, + title: 'Respected Voice', + description: 'Reach 500 reputation score', + reward_points: 100, + }, + { + type: AchievementType.REPUTATION_1000, + title: 'Community Legend', + description: 'Reach 1000 reputation score', + reward_points: 300, + }, + ]; + + for (const achievement of achievements) { + await this.achievementsRepository.save(achievement); + } + + this.logger.log(`Initialized ${achievements.length} achievements`); + } + + async checkAndUnlockAchievements(user: User): Promise { + const fullUser = await this.usersRepository.findOne({ + where: { id: user.id }, + }); + + if (!fullUser) return; + + const achievementsToCheck = [ + { + type: AchievementType.FIRST_PREDICTION, + condition: fullUser.total_predictions >= 1, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_10, + condition: fullUser.correct_predictions >= 10, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_50, + condition: fullUser.correct_predictions >= 50, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_100, + condition: fullUser.correct_predictions >= 100, + }, + { + type: AchievementType.ACCURACY_75, + condition: + fullUser.total_predictions > 0 && + (fullUser.correct_predictions / fullUser.total_predictions) * 100 >= + 75, + }, + { + type: AchievementType.ACCURACY_90, + condition: + fullUser.total_predictions > 0 && + (fullUser.correct_predictions / fullUser.total_predictions) * 100 >= + 90, + }, + { + type: AchievementType.TOTAL_STAKED_1M, + condition: BigInt(fullUser.total_staked_stroops) >= BigInt(1000000), + }, + { + type: AchievementType.TOTAL_STAKED_10M, + condition: BigInt(fullUser.total_staked_stroops) >= BigInt(10000000), + }, + { + type: AchievementType.REPUTATION_500, + condition: fullUser.reputation_score >= 500, + }, + { + type: AchievementType.REPUTATION_1000, + condition: fullUser.reputation_score >= 1000, + }, + ]; + + for (const { type, condition } of achievementsToCheck) { + if (!condition) continue; + + const achievement = await this.achievementsRepository.findOne({ + where: { type }, + }); + + if (!achievement) continue; + + const existing = await this.userAchievementsRepository.findOne({ + where: { user: { id: user.id }, achievement: { id: achievement.id } }, + }); + + if (!existing) { + await this.userAchievementsRepository.save({ + user, + achievement, + is_unlocked: true, + unlocked_at: new Date(), + }); + + this.logger.log( + `Unlocked achievement "${achievement.title}" for user ${user.id}`, + ); + } + } + } + + async getUserAchievements( + userAddress: string, + ): Promise { + const user = await this.usersRepository.findOne({ + where: { stellar_address: userAddress }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + const userAchievements = await this.userAchievementsRepository.find({ + where: { user: { id: user.id } }, + relations: ['achievement'], + }); + + const allAchievements = await this.achievementsRepository.find(); + + return allAchievements.map((achievement) => { + const userAchievement = userAchievements.find( + (ua) => ua.achievement.id === achievement.id, + ); + + return { + id: achievement.id, + type: achievement.type, + title: achievement.title, + description: achievement.description, + icon_url: achievement.icon_url, + reward_points: achievement.reward_points, + is_unlocked: !!userAchievement?.is_unlocked, + unlocked_at: userAchievement?.unlocked_at || null, + }; + }); + } +} diff --git a/backend/src/achievements/dto/achievement-response.dto.ts b/backend/src/achievements/dto/achievement-response.dto.ts new file mode 100644 index 00000000..b0bca369 --- /dev/null +++ b/backend/src/achievements/dto/achievement-response.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AchievementType } from '../entities/achievement.entity'; + +export class AchievementResponseDto { + @ApiProperty() + id: string; + + @ApiProperty({ enum: AchievementType }) + type: AchievementType; + + @ApiProperty() + title: string; + + @ApiProperty() + description: string; + + @ApiProperty({ nullable: true }) + icon_url: string | null; + + @ApiProperty() + reward_points: number; + + @ApiProperty() + is_unlocked: boolean; + + @ApiProperty({ nullable: true }) + unlocked_at: Date | null; +} diff --git a/backend/src/achievements/entities/achievement.entity.ts b/backend/src/achievements/entities/achievement.entity.ts new file mode 100644 index 00000000..8e813ce6 --- /dev/null +++ b/backend/src/achievements/entities/achievement.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export enum AchievementType { + FIRST_PREDICTION = 'first_prediction', + CORRECT_PREDICTIONS_10 = 'correct_predictions_10', + CORRECT_PREDICTIONS_50 = 'correct_predictions_50', + CORRECT_PREDICTIONS_100 = 'correct_predictions_100', + ACCURACY_75 = 'accuracy_75', + ACCURACY_90 = 'accuracy_90', + TOTAL_STAKED_1M = 'total_staked_1m', + TOTAL_STAKED_10M = 'total_staked_10m', + REPUTATION_500 = 'reputation_500', + REPUTATION_1000 = 'reputation_1000', +} + +@Entity('achievements') +@Index(['type']) +export class Achievement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: AchievementType }) + type: AchievementType; + + @Column() + title: string; + + @Column() + description: string; + + @Column({ nullable: true }) + icon_url: string; + + @Column({ default: 0 }) + reward_points: number; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/achievements/entities/user-achievement.entity.ts b/backend/src/achievements/entities/user-achievement.entity.ts new file mode 100644 index 00000000..fe7e6492 --- /dev/null +++ b/backend/src/achievements/entities/user-achievement.entity.ts @@ -0,0 +1,35 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + Index, + JoinColumn, + Unique, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Achievement } from './achievement.entity'; + +@Entity('user_achievements') +@Unique('UQ_user_achievement', ['user', 'achievement']) +@Index(['user']) +@Index(['achievement']) +export class UserAchievement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE', eager: false }) + @JoinColumn({ name: 'userId' }) + user: User; + + @ManyToOne(() => Achievement, { onDelete: 'CASCADE', eager: false }) + @JoinColumn({ name: 'achievementId' }) + achievement: Achievement; + + @Column({ default: false }) + is_unlocked: boolean; + + @CreateDateColumn() + unlocked_at: Date; +} diff --git a/backend/src/analytics/analytics.controller.spec.ts b/backend/src/analytics/analytics.controller.spec.ts index 49b6e3a3..ec6eba1e 100644 --- a/backend/src/analytics/analytics.controller.spec.ts +++ b/backend/src/analytics/analytics.controller.spec.ts @@ -6,6 +6,7 @@ import { Market } from '../markets/entities/market.entity'; import { Prediction } from '../predictions/entities/prediction.entity'; import { User } from '../users/entities/user.entity'; import { ActivityLog } from './entities/activity-log.entity'; +import { MarketHistory } from './entities/market-history.entity'; import { AnalyticsController } from './analytics.controller'; import { AnalyticsService } from './analytics.service'; @@ -126,6 +127,14 @@ describe('AnalyticsController', () => { findAndCount: jest.fn(), }, }, + { + provide: getRepositoryToken(MarketHistory), + useValue: { + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }, + }, ], }).compile(); diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts index b1580d07..2351a820 100644 --- a/backend/src/analytics/analytics.controller.ts +++ b/backend/src/analytics/analytics.controller.ts @@ -11,6 +11,7 @@ import { User } from '../users/entities/user.entity'; import { AnalyticsService } from './analytics.service'; import { DashboardKpisDto } from './dto/dashboard-kpis.dto'; import { MarketAnalyticsDto } from './dto/market-analytics.dto'; +import { MarketHistoryResponseDto } from './dto/market-history.dto'; @ApiTags('Analytics') @Controller('analytics') @@ -46,4 +47,20 @@ export class AnalyticsController { ): Promise { return this.analyticsService.getMarketAnalytics(id); } + + @Get('markets/:id/history') + @Public() + @ApiOperation({ summary: 'Get historical data for a market over time' }) + @ApiResponse({ + status: 200, + description: + 'Market history with prediction volume, pool size, and participant growth', + type: MarketHistoryResponseDto, + }) + @ApiResponse({ status: 404, description: 'Market not found' }) + async getMarketHistory( + @Param('id') id: string, + ): Promise { + return this.analyticsService.getMarketHistory(id); + } } diff --git a/backend/src/analytics/analytics.module.ts b/backend/src/analytics/analytics.module.ts index eede5d93..4452f626 100644 --- a/backend/src/analytics/analytics.module.ts +++ b/backend/src/analytics/analytics.module.ts @@ -7,6 +7,7 @@ import { User } from '../users/entities/user.entity'; import { AnalyticsController } from './analytics.controller'; import { AnalyticsService } from './analytics.service'; import { ActivityLog } from './entities/activity-log.entity'; +import { MarketHistory } from './entities/market-history.entity'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { ActivityLog } from './entities/activity-log.entity'; LeaderboardEntry, Market, ActivityLog, + MarketHistory, ]), ], controllers: [AnalyticsController], diff --git a/backend/src/analytics/analytics.service.history.spec.ts b/backend/src/analytics/analytics.service.history.spec.ts new file mode 100644 index 00000000..2b8ded25 --- /dev/null +++ b/backend/src/analytics/analytics.service.history.spec.ts @@ -0,0 +1,127 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AnalyticsService } from './analytics.service'; +import { Market } from '../markets/entities/market.entity'; +import { MarketHistory } from './entities/market-history.entity'; +import { User } from '../users/entities/user.entity'; +import { Prediction } from '../predictions/entities/prediction.entity'; +import { LeaderboardEntry } from '../leaderboard/entities/leaderboard-entry.entity'; +import { ActivityLog } from './entities/activity-log.entity'; + +describe('AnalyticsService - Market History', () => { + let service: AnalyticsService; + let marketHistoryRepository: jest.Mocked>; + let marketsRepository: jest.Mocked>; + let predictionsRepository: jest.Mocked>; + + const mockMarket = { + id: 'market-1', + on_chain_market_id: 'market_123', + title: 'Test Market', + outcome_options: ['YES', 'NO'], + participant_count: 10, + total_pool_stroops: '5000000', + created_at: new Date(), + } as Market; + + beforeEach(async () => { + marketHistoryRepository = { + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + } as any; + + marketsRepository = { + findOne: jest.fn().mockResolvedValue(mockMarket), + } as any; + + predictionsRepository = { + find: jest.fn().mockResolvedValue([]), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AnalyticsService, + { + provide: getRepositoryToken(User), + useValue: {}, + }, + { + provide: getRepositoryToken(Prediction), + useValue: predictionsRepository, + }, + { + provide: getRepositoryToken(LeaderboardEntry), + useValue: {}, + }, + { + provide: getRepositoryToken(Market), + useValue: marketsRepository, + }, + { + provide: getRepositoryToken(ActivityLog), + useValue: {}, + }, + { + provide: getRepositoryToken(MarketHistory), + useValue: marketHistoryRepository, + }, + ], + }).compile(); + + service = module.get(AnalyticsService); + }); + + it('should get market history', async () => { + const mockHistory = [ + { + recorded_at: new Date(), + prediction_volume: 5, + pool_size_stroops: '2500000', + participant_count: 5, + outcome_probabilities: ['50.00', '50.00'], + }, + { + recorded_at: new Date(Date.now() + 1000), + prediction_volume: 10, + pool_size_stroops: '5000000', + participant_count: 10, + outcome_probabilities: ['60.00', '40.00'], + }, + ] as MarketHistory[]; + + marketHistoryRepository.find.mockResolvedValue(mockHistory); + + const result = await service.getMarketHistory('market-1'); + + expect(result.market_id).toBe('market-1'); + expect(result.title).toBe('Test Market'); + expect(result.history).toHaveLength(2); + expect(result.history[0].prediction_volume).toBe(5); + }); + + it('should record market snapshot', async () => { + marketHistoryRepository.create.mockReturnValue({ + market: mockMarket, + recorded_at: new Date(), + prediction_volume: 10, + pool_size_stroops: '5000000', + participant_count: 10, + outcome_probabilities: ['50.00', '50.00'], + } as MarketHistory); + + marketHistoryRepository.save.mockResolvedValue({} as MarketHistory); + + await service.recordMarketSnapshot(mockMarket); + + expect(marketHistoryRepository.create).toHaveBeenCalled(); + expect(marketHistoryRepository.save).toHaveBeenCalled(); + }); + + it('should throw NotFoundException for non-existent market', async () => { + marketsRepository.findOne.mockResolvedValue(null); + + await expect(service.getMarketHistory('invalid-id')).rejects.toThrow(); + }); +}); diff --git a/backend/src/analytics/analytics.service.spec.ts b/backend/src/analytics/analytics.service.spec.ts index a892fdff..2b9e7ea7 100644 --- a/backend/src/analytics/analytics.service.spec.ts +++ b/backend/src/analytics/analytics.service.spec.ts @@ -11,6 +11,7 @@ import { Prediction } from '../predictions/entities/prediction.entity'; import { LeaderboardEntry } from '../leaderboard/entities/leaderboard-entry.entity'; import { Market } from '../markets/entities/market.entity'; import { ActivityLog } from './entities/activity-log.entity'; +import { MarketHistory } from './entities/market-history.entity'; describe('predictorTierFromReputation', () => { it('maps thresholds to tier labels', () => { @@ -99,6 +100,14 @@ describe('AnalyticsService', () => { findAndCount: jest.fn(), }, }, + { + provide: getRepositoryToken(MarketHistory), + useValue: { + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }, + }, ], }).compile(); diff --git a/backend/src/analytics/analytics.service.ts b/backend/src/analytics/analytics.service.ts index bee80d89..87af4253 100644 --- a/backend/src/analytics/analytics.service.ts +++ b/backend/src/analytics/analytics.service.ts @@ -6,11 +6,13 @@ import { Market } from '../markets/entities/market.entity'; import { Prediction } from '../predictions/entities/prediction.entity'; import { User } from '../users/entities/user.entity'; import { ActivityLog } from './entities/activity-log.entity'; +import { MarketHistory } from './entities/market-history.entity'; import { DashboardKpisDto } from './dto/dashboard-kpis.dto'; import { MarketAnalyticsDto, OutcomeDistributionDto, } from './dto/market-analytics.dto'; +import { MarketHistoryResponseDto } from './dto/market-history.dto'; /** Tier thresholds: Bronze < 200, Silver < 500, Gold < 1000, Platinum ≥ 1000 */ export function predictorTierFromReputation(reputationScore: number): string { @@ -40,6 +42,8 @@ export class AnalyticsService { private readonly marketsRepository: Repository, @InjectRepository(ActivityLog) private readonly activityLogsRepository: Repository, + @InjectRepository(MarketHistory) + private readonly marketHistoryRepository: Repository, ) {} async logActivity( @@ -177,4 +181,78 @@ export class AnalyticsService { time_remaining_seconds: timeRemainingSeconds, }; } + + /** + * Get historical data for a market: prediction volume, pool size, participant growth over time + */ + async getMarketHistory(marketId: string): Promise { + const market = await this.marketsRepository.findOne({ + where: [{ id: marketId }, { on_chain_market_id: marketId }], + }); + + if (!market) { + throw new NotFoundException(`Market "${marketId}" not found`); + } + + const history = await this.marketHistoryRepository.find({ + where: { market: { id: market.id } }, + order: { recorded_at: 'ASC' }, + }); + + const historyPoints = history.map((h) => ({ + timestamp: h.recorded_at, + prediction_volume: h.prediction_volume, + pool_size_stroops: h.pool_size_stroops, + participant_count: h.participant_count, + outcome_probabilities: h.outcome_probabilities + ? h.outcome_probabilities.map((p) => parseFloat(p)) + : null, + })); + + this.logger.log( + `Market history retrieved for "${market.title}" (${market.id}) - ${historyPoints.length} data points`, + ); + + return { + market_id: market.id, + title: market.title, + history: historyPoints, + generated_at: new Date(), + }; + } + + /** + * Record market snapshot for historical tracking + */ + async recordMarketSnapshot(market: Market): Promise { + const predictions = await this.predictionsRepository.find({ + where: { market: { id: market.id } }, + }); + + const outcomeCounts = new Map(); + market.outcome_options.forEach((outcome) => { + outcomeCounts.set(outcome, 0); + }); + + predictions.forEach((prediction) => { + const currentCount = outcomeCounts.get(prediction.chosen_outcome) || 0; + outcomeCounts.set(prediction.chosen_outcome, currentCount + 1); + }); + + const total = predictions.length; + const probabilities = Array.from(outcomeCounts.values()).map((count) => + total > 0 ? ((count / total) * 100).toFixed(2) : '0.00', + ); + + const snapshot = this.marketHistoryRepository.create({ + market, + recorded_at: new Date(), + prediction_volume: total, + pool_size_stroops: market.total_pool_stroops, + participant_count: market.participant_count, + outcome_probabilities: probabilities, + }); + + await this.marketHistoryRepository.save(snapshot); + } } diff --git a/backend/src/analytics/dto/market-history.dto.ts b/backend/src/analytics/dto/market-history.dto.ts new file mode 100644 index 00000000..faaf8e5d --- /dev/null +++ b/backend/src/analytics/dto/market-history.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class MarketHistoryPointDto { + @ApiProperty() + timestamp: Date; + + @ApiProperty() + prediction_volume: number; + + @ApiProperty() + pool_size_stroops: string; + + @ApiProperty() + participant_count: number; + + @ApiProperty({ type: [Number], nullable: true }) + outcome_probabilities: number[] | null; +} + +export class MarketHistoryResponseDto { + @ApiProperty() + market_id: string; + + @ApiProperty() + title: string; + + @ApiProperty({ type: [MarketHistoryPointDto] }) + history: MarketHistoryPointDto[]; + + @ApiProperty() + generated_at: Date; +} diff --git a/backend/src/analytics/entities/market-history.entity.ts b/backend/src/analytics/entities/market-history.entity.ts new file mode 100644 index 00000000..8cf2ff95 --- /dev/null +++ b/backend/src/analytics/entities/market-history.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + Index, + JoinColumn, +} from 'typeorm'; +import { Market } from '../../markets/entities/market.entity'; + +@Entity('market_history') +@Index(['market', 'recorded_at']) +@Index(['market']) +export class MarketHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Market, { onDelete: 'CASCADE', eager: false }) + @JoinColumn({ name: 'marketId' }) + market: Market; + + @Column({ type: 'timestamptz' }) + recorded_at: Date; + + @Column({ default: 0 }) + prediction_volume: number; + + @Column({ type: 'bigint', default: '0' }) + pool_size_stroops: string; + + @Column({ default: 0 }) + participant_count: number; + + @Column('simple-array', { nullable: true }) + outcome_probabilities: string[]; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 11710652..861e67f1 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { CommonModule } from './common/common.module'; import { AdminModule } from './admin/admin.module'; +import { AchievementsModule } from './achievements/achievements.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { RolesGuard } from './common/guards/roles.guard'; import { CompetitionsModule } from './competitions/competitions.module'; @@ -77,6 +78,7 @@ import { UsersModule } from './users/users.module'; NotificationsModule, SorobanModule, AdminModule, + AchievementsModule, CommonModule, AnalyticsModule, ], diff --git a/backend/src/markets/dto/bulk-create-markets.dto.ts b/backend/src/markets/dto/bulk-create-markets.dto.ts new file mode 100644 index 00000000..a8572c17 --- /dev/null +++ b/backend/src/markets/dto/bulk-create-markets.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsArray, + ArrayMinSize, + ArrayMaxSize, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { CreateMarketDto } from './create-market.dto'; + +export class BulkCreateMarketsDto { + @ApiProperty({ + description: 'Array of market DTOs to create (max 10)', + type: [CreateMarketDto], + minItems: 1, + maxItems: 10, + }) + @IsArray() + @ArrayMinSize(1, { message: 'At least 1 market is required' }) + @ArrayMaxSize(10, { message: 'Maximum 10 markets per request' }) + @ValidateNested({ each: true }) + @Type(() => CreateMarketDto) + markets: CreateMarketDto[]; +} diff --git a/backend/src/markets/dto/market-report.dto.ts b/backend/src/markets/dto/market-report.dto.ts new file mode 100644 index 00000000..995c6942 --- /dev/null +++ b/backend/src/markets/dto/market-report.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PredictionOutcomeDto { + @ApiProperty() + outcome: string; + + @ApiProperty() + count: number; + + @ApiProperty() + percentage: number; + + @ApiProperty() + total_staked_stroops: string; +} + +export class MarketEventDto { + @ApiProperty() + timestamp: Date; + + @ApiProperty() + event_type: string; + + @ApiProperty() + description: string; +} + +export class MarketReportDto { + @ApiProperty() + market_id: string; + + @ApiProperty() + title: string; + + @ApiProperty() + description: string; + + @ApiProperty() + category: string; + + @ApiProperty() + created_at: Date; + + @ApiProperty() + end_time: Date; + + @ApiProperty() + resolution_time: Date; + + @ApiProperty() + is_resolved: boolean; + + @ApiProperty({ nullable: true }) + resolved_outcome: string | null; + + @ApiProperty() + total_participants: number; + + @ApiProperty() + total_pool_stroops: string; + + @ApiProperty({ type: [PredictionOutcomeDto] }) + outcome_distribution: PredictionOutcomeDto[]; + + @ApiProperty({ type: [MarketEventDto] }) + timeline: MarketEventDto[]; + + @ApiProperty() + generated_at: Date; +} diff --git a/backend/src/markets/markets.controller.ts b/backend/src/markets/markets.controller.ts index f0048540..8aec3687 100644 --- a/backend/src/markets/markets.controller.ts +++ b/backend/src/markets/markets.controller.ts @@ -10,6 +10,7 @@ import { HttpStatus, UseGuards, } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; import { BanGuard } from '../common/guards/ban.guard'; import { PredictionStatsDto } from './dto/prediction-stats.dto'; import { @@ -23,6 +24,7 @@ import { Market } from './entities/market.entity'; import { Comment } from './entities/comment.entity'; import { MarketTemplate } from './entities/market-template.entity'; import { CreateMarketDto } from './dto/create-market.dto'; +import { BulkCreateMarketsDto } from './dto/bulk-create-markets.dto'; import { CreateCommentDto } from './dto/create-comment.dto'; import { ListMarketsDto, @@ -81,6 +83,31 @@ export class MarketsController { return this.marketsService.create(dto, user); } + @Post('bulk') + @UseGuards(BanGuard) + @Throttle({ default: { limit: 5, ttl: 60000 } }) + @HttpCode(HttpStatus.CREATED) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Bulk create prediction markets (max 10 per request)', + }) + @ApiResponse({ + status: 201, + description: 'Markets created', + type: [Market], + }) + @ApiResponse({ + status: 400, + description: 'Validation error or exceeds limit', + }) + @ApiResponse({ status: 502, description: 'Soroban contract call failed' }) + async bulkCreateMarkets( + @Body() dto: BulkCreateMarketsDto, + @CurrentUser() user: User, + ): Promise { + return this.marketsService.createBulk(dto.markets, user); + } + @Get() @Public() @ApiOperation({ summary: 'List and filter markets with pagination' }) @@ -149,4 +176,18 @@ export class MarketsController { async getComments(@Param('id') id: string): Promise { return this.marketsService.getComments(id); } + + @Get(':id/report') + @Public() + @ApiOperation({ + summary: 'Generate detailed market report with anonymized predictions', + }) + @ApiResponse({ + status: 200, + description: 'Market report with outcome distribution and timeline', + }) + @ApiResponse({ status: 404, description: 'Market not found' }) + async getMarketReport(@Param('id') id: string): Promise { + return this.marketsService.generateMarketReport(id); + } } diff --git a/backend/src/markets/markets.service.bulk.spec.ts b/backend/src/markets/markets.service.bulk.spec.ts new file mode 100644 index 00000000..c9549a1e --- /dev/null +++ b/backend/src/markets/markets.service.bulk.spec.ts @@ -0,0 +1,158 @@ +import { BadRequestException, BadGatewayException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { MarketsService } from './markets.service'; +import { Market } from './entities/market.entity'; +import { Comment } from './entities/comment.entity'; +import { MarketTemplate } from './entities/market-template.entity'; +import { SorobanService } from '../soroban/soroban.service'; +import { UsersService } from '../users/users.service'; +import { User } from '../users/entities/user.entity'; +import { CreateMarketDto } from './dto/create-market.dto'; + +describe('MarketsService - Bulk Creation', () => { + let service: MarketsService; + let marketsRepository: jest.Mocked>; + let dataSource: jest.Mocked; + let sorobanService: jest.Mocked; + + const mockUser = { + id: 'user-1', + stellar_address: 'GABC123', + } as User; + + const makeCreateDto = (): CreateMarketDto => ({ + title: 'Test Market', + description: 'Test description for market', + category: 'Crypto' as any, + outcome_options: ['YES', 'NO'], + end_time: new Date(Date.now() + 60_000).toISOString(), + resolution_time: new Date(Date.now() + 120_000).toISOString(), + creator_fee_bps: 100, + min_stake_stroops: '1000', + max_stake_stroops: '1000000', + is_public: true, + }); + + beforeEach(async () => { + marketsRepository = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + } as any; + + const mockQueryRunner = { + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { + create: jest.fn(), + save: jest.fn(), + }, + }; + + dataSource = { + createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), + } as any; + + sorobanService = { + createMarket: jest.fn().mockResolvedValue({ + market_id: 'market_123', + tx_hash: 'tx_hash_123', + }), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MarketsService, + { + provide: getRepositoryToken(Market), + useValue: marketsRepository, + }, + { + provide: getRepositoryToken(Comment), + useValue: {}, + }, + { + provide: getRepositoryToken(MarketTemplate), + useValue: {}, + }, + { + provide: UsersService, + useValue: {}, + }, + { + provide: SorobanService, + useValue: sorobanService, + }, + { + provide: DataSource, + useValue: dataSource, + }, + ], + }).compile(); + + service = module.get(MarketsService); + }); + + it('should reject bulk creation with more than 10 markets', async () => { + const dtos: CreateMarketDto[] = Array(11).fill(makeCreateDto()); + + // The validation happens at DTO level via class-validator + // So we just verify the service accepts up to 10 + const result = await service.createBulk(dtos.slice(0, 10), mockUser); + expect(result).toHaveLength(10); + }); + + it('should reject if any market has past end_time', async () => { + const dto = makeCreateDto(); + dto.end_time = new Date(Date.now() - 1000).toISOString(); + + await expect(service.createBulk([dto], mockUser)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should create multiple markets in transaction', async () => { + const dtos = [makeCreateDto(), makeCreateDto()]; + const mockQueryRunner = dataSource.createQueryRunner(); + + mockQueryRunner.manager.create.mockImplementation( + (entity, data) => + ({ + ...data, + id: 'market-' + Math.random(), + }) as Market, + ); + + mockQueryRunner.manager.save.mockResolvedValue({ + id: 'market-123', + on_chain_market_id: 'market_123', + } as Market); + + const result = await service.createBulk(dtos, mockUser); + + expect(mockQueryRunner.startTransaction).toHaveBeenCalled(); + expect(mockQueryRunner.commitTransaction).toHaveBeenCalled(); + expect(result).toHaveLength(2); + }); + + it('should rollback transaction on Soroban failure', async () => { + const dtos = [makeCreateDto()]; + const mockQueryRunner = dataSource.createQueryRunner(); + + sorobanService.createMarket.mockRejectedValueOnce( + new Error('Soroban error'), + ); + + await expect(service.createBulk(dtos, mockUser)).rejects.toThrow( + BadGatewayException, + ); + + expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/markets/markets.service.spec.ts b/backend/src/markets/markets.service.spec.ts index c6136104..713d5899 100644 --- a/backend/src/markets/markets.service.spec.ts +++ b/backend/src/markets/markets.service.spec.ts @@ -1,7 +1,7 @@ import { BadRequestException, ConflictException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, DataSource } from 'typeorm'; import { SorobanService } from '../soroban/soroban.service'; import { UsersService } from '../users/users.service'; import { User } from '../users/entities/user.entity'; @@ -21,6 +21,7 @@ describe('MarketsService', () => { let sorobanService: jest.Mocked< Pick >; + let dataSource: jest.Mocked; const mockUser = { id: 'user-1', @@ -53,6 +54,20 @@ describe('MarketsService', () => { resolveMarket: jest.fn(), }; + dataSource = { + createQueryRunner: jest.fn().mockReturnValue({ + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { + create: jest.fn(), + save: jest.fn(), + }, + }), + } as any; + const module: TestingModule = await Test.createTestingModule({ providers: [ MarketsService, @@ -76,6 +91,10 @@ describe('MarketsService', () => { provide: SorobanService, useValue: sorobanService, }, + { + provide: DataSource, + useValue: dataSource, + }, ], }).compile(); diff --git a/backend/src/markets/markets.service.ts b/backend/src/markets/markets.service.ts index 51f0d64e..ce6742ef 100644 --- a/backend/src/markets/markets.service.ts +++ b/backend/src/markets/markets.service.ts @@ -8,7 +8,7 @@ import { } from '@nestjs/common'; import { PredictionStatsDto } from './dto/prediction-stats.dto'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, DataSource } from 'typeorm'; import { Market } from './entities/market.entity'; import { Comment } from './entities/comment.entity'; import { MarketTemplate } from './entities/market-template.entity'; @@ -36,6 +36,7 @@ export class MarketsService { private readonly marketTemplatesRepository: Repository, private readonly usersService: UsersService, private readonly sorobanService: SorobanService, + private readonly dataSource: DataSource, ) {} /** @@ -71,6 +72,80 @@ export class MarketsService { return this.createMarket(dto, user); } + /** + * Bulk create markets with transaction support. + * Validates all markets before creating any. + * Rolls back all if any creation fails. + */ + async createBulk(dtos: CreateMarketDto[], user: User): Promise { + // Validate all DTOs first + for (const dto of dtos) { + const endTime = new Date(dto.end_time); + if (endTime <= new Date()) { + throw new BadRequestException('end_time must be in the future'); + } + } + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const createdMarkets: Market[] = []; + + for (const dto of dtos) { + // Call Soroban contract + let onChainMarketId: string; + try { + const result = await this.sorobanService.createMarket( + dto.title, + dto.description, + dto.category, + dto.outcome_options, + dto.end_time, + dto.resolution_time, + ); + onChainMarketId = result.market_id; + } catch (err) { + this.logger.error('Soroban createMarket failed', err); + throw new BadGatewayException('Failed to create market on Soroban'); + } + + // Create market entity + const market = queryRunner.manager.create(Market, { + on_chain_market_id: onChainMarketId, + creator: user, + title: dto.title, + description: dto.description, + category: dto.category, + outcome_options: dto.outcome_options, + end_time: new Date(dto.end_time), + resolution_time: new Date(dto.resolution_time), + is_public: dto.is_public, + is_resolved: false, + is_cancelled: false, + total_pool_stroops: '0', + participant_count: 0, + }); + + const saved = await queryRunner.manager.save(market); + createdMarkets.push(saved); + this.logger.log( + `Bulk created market "${dto.title}" with on_chain_id: ${onChainMarketId}`, + ); + } + + await queryRunner.commitTransaction(); + return createdMarkets; + } catch (err) { + await queryRunner.rollbackTransaction(); + this.logger.error('Bulk market creation failed, rolling back', err); + throw err; + } finally { + await queryRunner.release(); + } + } + async createMarket(dto: CreateMarketDto, user: User): Promise { const endTime = new Date(dto.end_time); if (endTime <= new Date()) { @@ -352,4 +427,66 @@ export class MarketsService { order: { category: 'ASC', title: 'ASC' }, }); } + + /** + * Generate a detailed market report with anonymized predictions + */ + async generateMarketReport(marketId: string): Promise { + const market = await this.findByIdOrOnChainId(marketId); + + // Get prediction stats (anonymized) + const stats = await this.getPredictionStats(marketId); + + const outcomeDistribution = stats.map((stat) => ({ + outcome: stat.outcome, + count: stat.count, + percentage: + market.participant_count > 0 + ? ((stat.count / market.participant_count) * 100).toFixed(2) + : '0.00', + total_staked_stroops: stat.total_staked_stroops, + })); + + // Build timeline of events + const timeline = [ + { + timestamp: market.created_at, + event_type: 'market_created', + description: `Market "${market.title}" was created`, + }, + { + timestamp: market.end_time, + event_type: 'market_ended', + description: 'Market ended - no more predictions accepted', + }, + ]; + + if (market.is_resolved) { + timeline.push({ + timestamp: new Date(), // Use current time as resolution was just checked + event_type: 'market_resolved', + description: `Market resolved with outcome: ${market.resolved_outcome}`, + }); + } + + return { + market_id: market.id, + title: market.title, + description: market.description, + category: market.category, + created_at: market.created_at, + end_time: market.end_time, + resolution_time: market.resolution_time, + is_resolved: market.is_resolved, + resolved_outcome: market.resolved_outcome || null, + total_participants: market.participant_count, + total_pool_stroops: market.total_pool_stroops, + outcome_distribution: outcomeDistribution, + timeline: timeline.sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ), + generated_at: new Date(), + }; + } } diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 2eccf302..6dfb04df 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -155,7 +155,11 @@ describe('UsersService', () => { jest .spyOn(participantsRepository, 'createQueryBuilder') - .mockReturnValue(queryBuilder as any); + .mockReturnValue( + queryBuilder as any as ReturnType< + typeof participantsRepository.createQueryBuilder + >, + ); const result = await service.findUserCompetitions( mockUser.stellar_address, @@ -231,7 +235,11 @@ describe('UsersService', () => { jest .spyOn(predictionsRepository, 'createQueryBuilder') - .mockReturnValue(queryBuilder as any); + .mockReturnValue( + queryBuilder as any as ReturnType< + typeof predictionsRepository.createQueryBuilder + >, + ); const result = await service.findPublicPredictionsByAddress( mockUser.stellar_address, @@ -269,7 +277,11 @@ describe('UsersService', () => { queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); jest .spyOn(marketsRepository, 'createQueryBuilder') - .mockReturnValue(queryBuilder as any); + .mockReturnValue( + queryBuilder as any as ReturnType< + typeof marketsRepository.createQueryBuilder + >, + ); const result = await service.findMarketsByAddress( mockUser.stellar_address, @@ -292,7 +304,11 @@ describe('UsersService', () => { queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); jest .spyOn(marketsRepository, 'createQueryBuilder') - .mockReturnValue(queryBuilder as any); + .mockReturnValue( + queryBuilder as any as ReturnType< + typeof marketsRepository.createQueryBuilder + >, + ); await service.findMarketsByAddress(mockUser.stellar_address, { status: UserMarketFilterStatus.Active, @@ -308,7 +324,11 @@ describe('UsersService', () => { queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); jest .spyOn(marketsRepository, 'createQueryBuilder') - .mockReturnValue(queryBuilder as any); + .mockReturnValue( + queryBuilder as any as ReturnType< + typeof marketsRepository.createQueryBuilder + >, + ); await service.findMarketsByAddress(mockUser.stellar_address, { status: UserMarketFilterStatus.Resolved, @@ -324,7 +344,11 @@ describe('UsersService', () => { queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); jest .spyOn(marketsRepository, 'createQueryBuilder') - .mockReturnValue(queryBuilder as any); + .mockReturnValue( + queryBuilder as any as ReturnType< + typeof marketsRepository.createQueryBuilder + >, + ); await service.findMarketsByAddress(mockUser.stellar_address, { status: UserMarketFilterStatus.Cancelled, @@ -340,7 +364,11 @@ describe('UsersService', () => { queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); jest .spyOn(marketsRepository, 'createQueryBuilder') - .mockReturnValue(queryBuilder as any); + .mockReturnValue( + queryBuilder as any as ReturnType< + typeof marketsRepository.createQueryBuilder + >, + ); await service.findMarketsByAddress(mockUser.stellar_address, { sort_by: UserMarketsSortBy.ParticipantCount,