diff --git a/AGENTS.md b/AGENTS.md index 6652408..1960dab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ # Memory Context -# $CMEM MemeMind-Server 2026-05-10 9:40pm GMT+8 +# $CMEM MemeMind-Server 2026-05-13 8:45am GMT+8 No previous sessions found. \ No newline at end of file diff --git a/docs/api/share-challenge-api.md b/docs/api/share-challenge-api.md index 57d2782..34bd534 100644 --- a/docs/api/share-challenge-api.md +++ b/docs/api/share-challenge-api.md @@ -4,6 +4,8 @@ ## Changelog +- 2026-05-13: 新增 `GET /api/v1/share/{code}` 分享挑战详情接口,返回挑战基本信息和所有已提交用户的排行榜;排行榜项包含用户头像、昵称、答对题数和总耗时。 +- 2026-05-12: `GET /api/v1/share/created` 响应新增 `firstPlaceUser`,返回每个挑战当前第一名用户的昵称和头像;暂无提交结果时为 `null`。 - 2026-05-10: 移除 `POST /api/v1/share/progress` 单关进度上报接口;新增 `POST /api/v1/share/{code}/submit`,客户端一次提交整场挑战的每关答案和耗时,服务端校验答案后返回排名、答对题数、参与人数和完整关卡答案,并持久化提交结果。 ## 目录 @@ -27,8 +29,9 @@ 1. **关卡时间限制**:`levels` 表新增 `time_limit` 字段,支持关卡通关时间限制 2. **整场挑战提交**:`POST /api/v1/share/{code}/submit` 接口,用于一次性提交分享挑战中每一关的答案和耗时 3. **挑战结果返回**:提交后返回当前用户排名、答对题数、参与人数、总耗时和每关校验结果 -4. **我创建的挑战列表**:`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数和本人排名 -5. **关卡排序**:关卡全局顺序按 `levels.sort_key` 的应用层字节序计算,接口中的 `sortOrder` 为排序后的 0-based 连续序号 +4. **分享挑战详情**:`GET /api/v1/share/{code}` 接口,用于查询单个挑战的基本信息和完整排行榜 +5. **我创建的挑战列表**:`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数、本人排名和当前第一名用户信息 +6. **关卡排序**:关卡全局顺序按 `levels.sort_key` 的应用层字节序计算,接口中的 `sortOrder` 为排序后的 0-based 连续序号 --- @@ -296,7 +299,105 @@ Content-Type: application/json --- -### 4. 获取我创建的分享挑战 +### 4. 获取分享挑战详情 + +获取单个分享挑战的基本信息,以及该挑战下所有已提交结果用户的排行榜。 + +**接口地址**:`GET /api/v1/share/{code}` + +**是否需要认证**:是(JWT Bearer Token) + +**路径参数**: + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ------ | ---- | -------------- | +| code | string | 是 | 分享码(8 位) | + +**响应数据**: + +```typescript +{ + id: string; // 分享挑战 ID + shareCode: string; // 分享码 + title: string; // 分享标题 + levelCount: number; // 挑战关卡总数 + participantCount: number; // 已提交结果的参与用户总数 + userRank: number | null; // 当前用户排名;尚未提交结果时为 null + createdAt: string; // 创建时间,ISO 8601 字符串 + rankings: [ + { + rank: number; // 名次,从 1 开始 + participantId: string; // 参与用户 ID + nickname: string | null; + avatarUrl: string | null; + correctCount: number; // 答对题数 + totalTimeSpent: number;// 总耗时(秒) + } + ]; +} +``` + +**成功响应示例**: + +```json +{ + "success": true, + "data": { + "id": "share_001", + "shareCode": "abc12345", + "title": "我的挑战", + "levelCount": 6, + "participantCount": 3, + "userRank": 2, + "createdAt": "2026-04-13T10:00:00.000Z", + "rankings": [ + { + "rank": 1, + "participantId": "user_002", + "nickname": "速度玩家", + "avatarUrl": "https://example.com/avatar-speed.png", + "correctCount": 6, + "totalTimeSpent": 90 + }, + { + "rank": 2, + "participantId": "user_001", + "nickname": "挑战创建者", + "avatarUrl": null, + "correctCount": 6, + "totalTimeSpent": 120 + }, + { + "rank": 3, + "participantId": "user_003", + "nickname": null, + "avatarUrl": null, + "correctCount": 5, + "totalTimeSpent": 60 + } + ] + }, + "message": null, + "timestamp": "2026-04-13T12:00:00.000Z" +} +``` + +**业务逻辑说明**: + +1. 返回挑战基本信息:分享 ID、分享码、标题、关卡数量、已提交人数、当前用户排名和创建时间。 +2. `rankings` 只包含已经调用 `POST /api/v1/share/{code}/submit` 提交整场挑战结果的用户。 +3. 排行榜按答对题数降序排列;答对题数相同时,按总耗时升序排列。 +4. 如果答对题数和总耗时都相同,服务端继续按提交时间升序、`participantId` 升序做稳定排序。 +5. `userRank` 表示当前登录用户在该挑战中的排名;当前用户尚未提交结果时为 `null`。 + +**客户端调用场景**: + +- 用户打开单个挑战详情页或结算页时调用。 +- 需要展示完整排行榜时调用;列表页仍使用 `GET /api/v1/share/created`。 + +--- + +### 5. 获取我创建的分享挑战 获取当前登录用户创建过的分享挑战列表。 @@ -322,6 +423,10 @@ Authorization: Bearer levelCount: number; // 关卡数量 participantCount: number; // 已提交结果的参与人数 userRank: number | null; // 当前用户在该挑战中的排名;尚未提交结果时为 null + firstPlaceUser: { // 当前第一名用户信息;暂无提交结果时为 null + nickname: string | null; // 第一名用户昵称 + avatarUrl: string | null;// 第一名用户头像 URL + } | null; createdAt: string; // 创建时间,ISO 8601 字符串 } ] @@ -342,6 +447,10 @@ Authorization: Bearer "levelCount": 6, "participantCount": 8, "userRank": 2, + "firstPlaceUser": { + "nickname": "第一名玩家", + "avatarUrl": "https://example.com/avatar-first.png" + }, "createdAt": "2026-04-13T10:00:00.000Z" }, { @@ -351,6 +460,10 @@ Authorization: Bearer "levelCount": 6, "participantCount": 1, "userRank": null, + "firstPlaceUser": { + "nickname": null, + "avatarUrl": null + }, "createdAt": "2026-04-12T09:00:00.000Z" } ] @@ -366,6 +479,7 @@ Authorization: Bearer 2. 排名按答对题数降序计算,答对越多排名越高。 3. 答对题数相同时,按总耗时升序、提交时间升序、`participantId` 升序做稳定排序。 4. `userRank` 表示当前登录用户在自己创建的该挑战中的排名。如果自己尚未提交挑战结果,则返回 `null`。 +5. `firstPlaceUser` 取当前排名第一的已提交用户资料。如果该挑战还没有任何提交结果,则返回 `null`。 **参与人数统计规则**: @@ -379,7 +493,7 @@ Authorization: Bearer --- -### 5. 提交分享挑战结果 +### 6. 提交分享挑战结果 用户完成分享挑战后,一次性提交分享中每一关的耗时和答案。服务端校验答案、持久化结果,并返回当前用户的排名和完整关卡答案。 @@ -803,7 +917,56 @@ const shareData = await joinShare(shareCode); // 保存关卡数据,开始游戏 ``` -### 5. 提交挑战结果 +### 5. 获取挑战详情 + +```typescript +interface ChallengeRankingItem { + rank: number; + participantId: string; + nickname: string | null; + avatarUrl: string | null; + correctCount: number; + totalTimeSpent: number; +} + +interface ShareChallengeDetailResponse { + id: string; + shareCode: string; + title: string; + levelCount: number; + participantCount: number; + userRank: number | null; + createdAt: string; + rankings: ChallengeRankingItem[]; +} + +async function getShareChallengeDetail( + shareCode: string, +): Promise { + // 确保已登录 + if (!httpManager.getToken()) { + await wxLogin(); + } + + const response = await httpManager.get( + `/v1/share/${shareCode}`, + ); + + if (response.success && response.data) { + console.log('挑战详情:', response.data); + return response.data; + } else { + throw new Error(response.message || '获取挑战详情失败'); + } +} + +// 使用示例 +const detail = await getShareChallengeDetail('abc12345'); +console.log(`当前排名: ${detail.userRank ?? '暂未上榜'}`); +console.log(`已提交人数: ${detail.participantCount}`); +``` + +### 6. 提交挑战结果 ```typescript interface SubmitChallengeLevel { @@ -870,7 +1033,7 @@ async function onChallengeFinished() { } ``` -### 6. 启动流程示例 +### 7. 启动流程示例 ```typescript // GameEntry.ts - 游戏入口脚本 diff --git a/src/modules/share/dto/share-response.dto.ts b/src/modules/share/dto/share-response.dto.ts index aaa0ed5..2eb92ea 100644 --- a/src/modules/share/dto/share-response.dto.ts +++ b/src/modules/share/dto/share-response.dto.ts @@ -60,6 +60,58 @@ export class JoinShareResponseDto { levels!: ShareLevelDto[]; } +export class ShareChallengeRankingItemDto { + @ApiProperty({ description: '排名,从 1 开始' }) + rank!: number; + + @ApiProperty({ description: '参与用户 ID' }) + participantId!: string; + + @ApiProperty({ description: '参与用户昵称', nullable: true }) + nickname!: string | null; + + @ApiProperty({ description: '参与用户头像 URL', nullable: true }) + avatarUrl!: string | null; + + @ApiProperty({ description: '答对题数' }) + correctCount!: number; + + @ApiProperty({ description: '总耗时(秒)' }) + totalTimeSpent!: number; +} + +export class ShareChallengeDetailResponseDto { + @ApiProperty({ description: '分享 ID' }) + id!: string; + + @ApiProperty({ description: '分享码' }) + shareCode!: string; + + @ApiProperty({ description: '分享标题' }) + title!: string; + + @ApiProperty({ description: '挑战关卡总数' }) + levelCount!: number; + + @ApiProperty({ description: '已提交结果的参与用户总数' }) + participantCount!: number; + + @ApiProperty({ + description: '当前用户在该挑战中的排名,尚未提交挑战结果时为 null', + nullable: true, + }) + userRank!: number | null; + + @ApiProperty({ description: '创建时间' }) + createdAt!: string; + + @ApiProperty({ + description: '挑战排行榜,按答对题数降序、总耗时升序排列', + type: [ShareChallengeRankingItemDto], + }) + rankings!: ShareChallengeRankingItemDto[]; +} + export class SubmittedShareLevelDto extends ShareLevelDto { @ApiProperty({ description: '用户提交的答案' }) submittedAnswer!: string; @@ -106,6 +158,14 @@ export class SubmitShareChallengeResponseDto { levels!: SubmittedShareLevelDto[]; } +export class FirstPlaceUserDto { + @ApiProperty({ description: '第一名用户昵称', nullable: true }) + nickname!: string | null; + + @ApiProperty({ description: '第一名用户头像 URL', nullable: true }) + avatarUrl!: string | null; +} + export class CreatedShareItemDto { @ApiProperty({ description: '分享 ID' }) id!: string; @@ -128,6 +188,13 @@ export class CreatedShareItemDto { }) userRank!: number | null; + @ApiProperty({ + description: '当前挑战第一名用户信息,暂无提交结果时为 null', + nullable: true, + type: () => FirstPlaceUserDto, + }) + firstPlaceUser!: FirstPlaceUserDto | null; + @ApiProperty({ description: '创建时间' }) createdAt!: string; } diff --git a/src/modules/share/repositories/share-participant.repository.ts b/src/modules/share/repositories/share-participant.repository.ts index c8e0669..e175e83 100644 --- a/src/modules/share/repositories/share-participant.repository.ts +++ b/src/modules/share/repositories/share-participant.repository.ts @@ -14,6 +14,8 @@ export type ShareParticipantRankingRow = { correctCount: number; totalTimeSpent: number; submittedAt: Date; + nickname?: string | null; + avatarUrl?: string | null; }; @Injectable() @@ -120,6 +122,12 @@ export class ShareParticipantRepository { ): Promise { const rows = await this.repository .createQueryBuilder('participant') + .leftJoin('participant.participant', 'rankingUser') + .addSelect([ + 'rankingUser.id', + 'rankingUser.nickname', + 'rankingUser.avatarUrl', + ]) .where('participant.shareConfigId = :shareConfigId', { shareConfigId }) .andWhere('participant.submittedAt IS NOT NULL') .orderBy('participant.correctCount', 'DESC') @@ -134,6 +142,8 @@ export class ShareParticipantRepository { correctCount: row.correctCount, totalTimeSpent: row.totalTimeSpent, submittedAt: row.submittedAt!, + nickname: row.participant?.nickname ?? null, + avatarUrl: row.participant?.avatarUrl ?? null, })); } @@ -146,6 +156,12 @@ export class ShareParticipantRepository { const rows = await this.repository .createQueryBuilder('participant') + .leftJoin('participant.participant', 'rankingUser') + .addSelect([ + 'rankingUser.id', + 'rankingUser.nickname', + 'rankingUser.avatarUrl', + ]) .where('participant.shareConfigId IN (:...shareConfigIds)', { shareConfigIds, }) @@ -163,6 +179,8 @@ export class ShareParticipantRepository { correctCount: row.correctCount, totalTimeSpent: row.totalTimeSpent, submittedAt: row.submittedAt!, + nickname: row.participant?.nickname ?? null, + avatarUrl: row.participant?.avatarUrl ?? null, })); } diff --git a/src/modules/share/share.controller.spec.ts b/src/modules/share/share.controller.spec.ts index 2e0fcde..b7fec13 100644 --- a/src/modules/share/share.controller.spec.ts +++ b/src/modules/share/share.controller.spec.ts @@ -16,6 +16,7 @@ describe('ShareController', () => { const mockShareService = { createShare: jest.fn(), getCreatedShares: jest.fn(), + getShareChallengeDetail: jest.fn(), joinShare: jest.fn(), submitChallenge: jest.fn(), }; @@ -72,6 +73,10 @@ describe('ShareController', () => { levelCount: 6, participantCount: 8, userRank: 2, + firstPlaceUser: { + nickname: '第一名玩家', + avatarUrl: 'https://example.com/avatar-first.png', + }, createdAt: '2026-01-01T00:00:00.000Z', }, ], @@ -90,6 +95,53 @@ describe('ShareController', () => { }); }); + describe('getShareChallengeDetail', () => { + it('should return success response with challenge detail and rankings', async () => { + const detailResponse = { + id: 'share-uuid-1', + shareCode: 'ABCD1234', + title: '我的挑战', + levelCount: 6, + participantCount: 2, + userRank: 2, + createdAt: '2026-01-01T00:00:00.000Z', + rankings: [ + { + rank: 1, + participantId: 'user-uuid-2', + nickname: '第一名玩家', + avatarUrl: 'https://example.com/avatar-first.png', + correctCount: 6, + totalTimeSpent: 98, + }, + { + rank: 2, + participantId: 'user-uuid-1', + nickname: '挑战创建者', + avatarUrl: null, + correctCount: 5, + totalTimeSpent: 120, + }, + ], + }; + mockShareService.getShareChallengeDetail.mockResolvedValue( + detailResponse, + ); + + const result = await controller.getShareChallengeDetail( + mockUser, + 'ABCD1234', + ); + + expect(result.success).toBe(true); + expect(result.data).toEqual(detailResponse); + expect(mockShareService.getShareChallengeDetail).toHaveBeenCalledWith( + 'user-uuid-1', + 'ABCD1234', + ); + }); + }); + describe('joinShare', () => { it('should return success response with share levels', async () => { const joinResponse = { diff --git a/src/modules/share/share.controller.ts b/src/modules/share/share.controller.ts index b732299..c106f08 100644 --- a/src/modules/share/share.controller.ts +++ b/src/modules/share/share.controller.ts @@ -11,6 +11,7 @@ import { CreateShareResponseDto, CreatedShareListResponseDto, JoinShareResponseDto, + ShareChallengeDetailResponseDto, SubmitShareChallengeResponseDto, } from './dto/share-response.dto'; import { ApiResponseDto } from '../../common/dto/api-response.dto'; @@ -56,6 +57,27 @@ export class ShareController { return ApiResponseDto.success(data); } + @Get(':code') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: '获取分享挑战详情', + description: + '返回分享挑战基本信息,以及所有已提交挑战结果用户的排行榜。排行榜按答对题数降序、总耗时升序排列。', + }) + @ApiResponse({ status: 200, description: '成功' }) + @ApiResponse({ status: 404, description: '分享不存在' }) + async getShareChallengeDetail( + @CurrentUser() user: JwtPayload, + @Param('code') code: string, + ): Promise> { + const data = await this.shareService.getShareChallengeDetail( + user.sub, + code, + ); + return ApiResponseDto.success(data); + } + @Post(':code/join') @UseGuards(JwtAuthGuard) @ApiBearerAuth() diff --git a/src/modules/share/share.service.spec.ts b/src/modules/share/share.service.spec.ts index 48eee77..871d21f 100644 --- a/src/modules/share/share.service.spec.ts +++ b/src/modules/share/share.service.spec.ts @@ -255,6 +255,8 @@ describe('ShareService', () => { correctCount: 6, totalTimeSpent: 100, submittedAt: new Date('2026-01-01T00:02:00.000Z'), + nickname: '第一名玩家', + avatarUrl: 'https://example.com/avatar-first.png', }, { shareConfigId: 'share-uuid-1', @@ -262,6 +264,8 @@ describe('ShareService', () => { correctCount: 6, totalTimeSpent: 120, submittedAt: new Date('2026-01-01T00:01:00.000Z'), + nickname: '挑战创建者', + avatarUrl: null, }, { shareConfigId: 'share-uuid-1', @@ -269,6 +273,8 @@ describe('ShareService', () => { correctCount: 5, totalTimeSpent: 200, submittedAt: new Date('2026-01-01T00:03:00.000Z'), + nickname: null, + avatarUrl: null, }, ], ); @@ -284,6 +290,7 @@ describe('ShareService', () => { levelCount: 6, participantCount: 1, userRank: null, + firstPlaceUser: null, createdAt: '2026-01-02T00:00:00.000Z', }, { @@ -293,6 +300,10 @@ describe('ShareService', () => { levelCount: 6, participantCount: 3, userRank: 2, + firstPlaceUser: { + nickname: '第一名玩家', + avatarUrl: 'https://example.com/avatar-first.png', + }, createdAt: '2026-01-01T00:00:00.000Z', }, ], @@ -331,6 +342,124 @@ describe('ShareService', () => { }); }); + describe('getShareChallengeDetail', () => { + it('should return challenge detail with all submitted rankings', async () => { + mockShareConfigRepository.findByShareCode.mockResolvedValue( + mockShareConfig, + ); + mockShareParticipantRepository.findSubmittedRankingsByShareConfigId.mockResolvedValue( + [ + { + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-3', + correctCount: 6, + totalTimeSpent: 90, + submittedAt: new Date('2026-01-01T00:03:00.000Z'), + nickname: '速度玩家', + avatarUrl: 'https://example.com/avatar-speed.png', + }, + { + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-1', + correctCount: 6, + totalTimeSpent: 120, + submittedAt: new Date('2026-01-01T00:02:00.000Z'), + nickname: '挑战创建者', + avatarUrl: null, + }, + { + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-2', + correctCount: 5, + totalTimeSpent: 60, + submittedAt: new Date('2026-01-01T00:01:00.000Z'), + nickname: null, + avatarUrl: null, + }, + ], + ); + + const result = await service.getShareChallengeDetail( + 'user-uuid-1', + 'ABCD1234', + ); + + expect(result).toEqual({ + id: 'share-uuid-1', + shareCode: 'ABCD1234', + title: '我的挑战', + levelCount: 6, + participantCount: 3, + userRank: 2, + createdAt: '2026-01-01T00:00:00.000Z', + rankings: [ + { + rank: 1, + participantId: 'user-uuid-3', + nickname: '速度玩家', + avatarUrl: 'https://example.com/avatar-speed.png', + correctCount: 6, + totalTimeSpent: 90, + }, + { + rank: 2, + participantId: 'user-uuid-1', + nickname: '挑战创建者', + avatarUrl: null, + correctCount: 6, + totalTimeSpent: 120, + }, + { + rank: 3, + participantId: 'user-uuid-2', + nickname: null, + avatarUrl: null, + correctCount: 5, + totalTimeSpent: 60, + }, + ], + }); + expect( + mockShareParticipantRepository.findSubmittedRankingsByShareConfigId, + ).toHaveBeenCalledWith('share-uuid-1'); + }); + + it('should return null userRank when current user has not submitted', async () => { + mockShareConfigRepository.findByShareCode.mockResolvedValue( + mockShareConfig, + ); + mockShareParticipantRepository.findSubmittedRankingsByShareConfigId.mockResolvedValue( + [ + { + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-2', + correctCount: 5, + totalTimeSpent: 80, + submittedAt: new Date('2026-01-01T00:01:00.000Z'), + nickname: null, + avatarUrl: null, + }, + ], + ); + + const result = await service.getShareChallengeDetail( + 'user-uuid-1', + 'ABCD1234', + ); + + expect(result.userRank).toBeNull(); + expect(result.participantCount).toBe(1); + }); + + it('should throw NotFoundException when share code not found', async () => { + mockShareConfigRepository.findByShareCode.mockResolvedValue(null); + + await expect( + service.getShareChallengeDetail('user-uuid-1', 'INVALID'), + ).rejects.toThrow(NotFoundException); + }); + }); + describe('submitChallenge', () => { const submitDto = { levels: mockLevels.map((level, index) => ({ diff --git a/src/modules/share/share.service.ts b/src/modules/share/share.service.ts index a5a3eee..564a42f 100644 --- a/src/modules/share/share.service.ts +++ b/src/modules/share/share.service.ts @@ -16,6 +16,7 @@ import { CreateShareResponseDto, CreatedShareListResponseDto, JoinShareResponseDto, + ShareChallengeDetailResponseDto, ShareLevelDto, SubmittedShareLevelDto, SubmitShareChallengeResponseDto, @@ -95,6 +96,43 @@ export class ShareService { }; } + async getShareChallengeDetail( + userId: string, + code: string, + ): Promise { + const config = await this.shareConfigRepository.findByShareCode(code); + if (!config) { + throw new NotFoundException('分享不存在或已过期'); + } + + const rankings = + await this.shareParticipantRepository.findSubmittedRankingsByShareConfigId( + config.id, + ); + const rankingItems = rankings.map((ranking, index) => ({ + rank: index + 1, + participantId: ranking.participantId, + nickname: ranking.nickname ?? null, + avatarUrl: ranking.avatarUrl ?? null, + correctCount: ranking.correctCount, + totalTimeSpent: ranking.totalTimeSpent, + })); + const userRanking = rankingItems.find( + (ranking) => ranking.participantId === userId, + ); + + return { + id: config.id, + shareCode: config.shareCode, + title: config.title, + levelCount: config.levelIds.length, + participantCount: rankingItems.length, + userRank: userRanking?.rank ?? null, + createdAt: config.createdAt.toISOString(), + rankings: rankingItems, + }; + } + async getCreatedShares(userId: string): Promise { const configs = await this.shareConfigRepository.findBySharerId(userId); if (configs.length === 0) { @@ -111,11 +149,11 @@ export class ShareService { ), ]); - const rankingsByShareConfigId = new Map(); + const rankingsByShareConfigId = new Map(); for (const config of configs) { - const rankings = rankingRows - .filter((row) => row.shareConfigId === config.id) - .map((row) => row.participantId); + const rankings = rankingRows.filter( + (row) => row.shareConfigId === config.id, + ); rankingsByShareConfigId.set(config.id, rankings); } @@ -124,8 +162,9 @@ export class ShareService { items: configs.map((config) => { const rankings = rankingsByShareConfigId.get(config.id) ?? []; const rankingIndex = rankings.findIndex( - (participantId) => participantId === userId, + (ranking) => ranking.participantId === userId, ); + const firstPlaceRanking = rankings[0]; return { id: config.id, @@ -134,6 +173,12 @@ export class ShareService { levelCount: config.levelIds.length, participantCount: participantCountMap.get(config.id) ?? 0, userRank: rankingIndex >= 0 ? rankingIndex + 1 : null, + firstPlaceUser: firstPlaceRanking + ? { + nickname: firstPlaceRanking.nickname ?? null, + avatarUrl: firstPlaceRanking.avatarUrl ?? null, + } + : null, createdAt: config.createdAt.toISOString(), }; }),