From 490064cf0510232714939a42b9356efa423ab56e Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Tue, 29 Jul 2025 07:33:47 -0700 Subject: [PATCH] Implement Paginated Recent Activity Endpoint for Use --- test/jest-e2e.json => jest-e2e.json | 21 ++--- src/users/controllers/users.controller.ts | 23 +++++- src/users/dtos/user-activity-event.dto.ts | 9 +++ src/users/dtos/user-activity-response.dto.ts | 7 ++ src/users/providers/PuzzleActivityProvider.ts | 67 ++++++++++++++++ src/users/providers/UserActivityService.ts | 11 +++ src/users/users.module.ts | 13 +++- test/user-activity.e2e-spec.ts | 76 +++++++++++++++++++ 8 files changed, 213 insertions(+), 14 deletions(-) rename test/jest-e2e.json => jest-e2e.json (73%) create mode 100644 src/users/dtos/user-activity-event.dto.ts create mode 100644 src/users/dtos/user-activity-response.dto.ts create mode 100644 src/users/providers/PuzzleActivityProvider.ts create mode 100644 src/users/providers/UserActivityService.ts create mode 100644 test/user-activity.e2e-spec.ts diff --git a/test/jest-e2e.json b/jest-e2e.json similarity index 73% rename from test/jest-e2e.json rename to jest-e2e.json index bb66802..81fa06d 100644 --- a/test/jest-e2e.json +++ b/jest-e2e.json @@ -1,9 +1,12 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", - "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - } -} +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^src/(.*)$": "/src/$1" + } +} diff --git a/src/users/controllers/users.controller.ts b/src/users/controllers/users.controller.ts index c802c85..e7b1ba9 100644 --- a/src/users/controllers/users.controller.ts +++ b/src/users/controllers/users.controller.ts @@ -14,11 +14,17 @@ import { UsersService } from '../providers/users.service'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { paginationQueryDto } from 'src/common/pagination/paginationQueryDto'; import { EditUserDto } from '../dtos/editUserDto.dto'; +import { UserActivityService } from '../providers/UserActivityService'; +import { UserActivityResponseDto } from '../dtos/user-activity-response.dto'; +import { ParseIntPipe, DefaultValuePipe, ValidationPipe } from '@nestjs/common'; @Controller('users') @ApiTags('users') export class UsersController { - constructor(private readonly usersService: UsersService) {} + constructor( + private readonly usersService: UsersService, + private readonly userActivityService: UserActivityService, + ) {} @Delete(':id') @ApiOperation({ summary: 'Delete user by ID' }) @@ -51,4 +57,19 @@ export class UsersController { async update(@Param('id') id: string, @Body() editUserDto: EditUserDto) { return this.usersService.update(id,editUserDto); } + + @Get(':id/activity') + @ApiOperation({ summary: 'Get recent activity for a user' }) + @ApiResponse({ status: 200, type: UserActivityResponseDto }) + async getUserActivity( + @Param('id') id: string, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(5), ParseIntPipe) limit: number, + ): Promise { + if (page < 1 || limit < 1) { + throw new Error('Page and limit must be positive integers'); + } + const activities = await this.userActivityService.getUserActivity(id, page, limit); + return { activities }; + } } diff --git a/src/users/dtos/user-activity-event.dto.ts b/src/users/dtos/user-activity-event.dto.ts new file mode 100644 index 0000000..1728763 --- /dev/null +++ b/src/users/dtos/user-activity-event.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UserActivityEventDto { + @ApiProperty({ example: 'Completed "Binary Tree Maximum Depth" puzzle' }) + description: string; + + @ApiProperty({ example: '2025-07-05T08:00:00Z' }) + timestamp: string; +} \ No newline at end of file diff --git a/src/users/dtos/user-activity-response.dto.ts b/src/users/dtos/user-activity-response.dto.ts new file mode 100644 index 0000000..2ead6f4 --- /dev/null +++ b/src/users/dtos/user-activity-response.dto.ts @@ -0,0 +1,7 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { UserActivityEventDto } from './user-activity-event.dto'; + +export class UserActivityResponseDto { + @ApiProperty({ type: [UserActivityEventDto] }) + activities: UserActivityEventDto[]; +} \ No newline at end of file diff --git a/src/users/providers/PuzzleActivityProvider.ts b/src/users/providers/PuzzleActivityProvider.ts new file mode 100644 index 0000000..9a5bad1 --- /dev/null +++ b/src/users/providers/PuzzleActivityProvider.ts @@ -0,0 +1,67 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PuzzleSubmission } from 'src/puzzle/entities/puzzle-submission.entity'; +import { UserAchievement } from 'src/achievement/entities/user-achievement.entity'; +import { User } from '../user.entity'; + +export interface UserActivityEvent { + description: string; + timestamp: string; +} + +@Injectable() +export class PuzzleActivityProvider { + constructor( + @InjectRepository(PuzzleSubmission) + private readonly puzzleSubmissionRepo: Repository, + @InjectRepository(UserAchievement) + private readonly userAchievementRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + ) {} + + async getRecentActivity(userId: string, page = 1, limit = 5): Promise { + // Validate user exists + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) throw new NotFoundException('User not found'); + + // Fetch puzzle submissions + const puzzleSubmissions = await this.puzzleSubmissionRepo.find({ + where: { user: { id: userId }, isCorrect: true }, + relations: ['puzzle'], + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + // Fetch achievements + const achievements = await this.userAchievementRepo.find({ + where: { user: { id: userId } }, + relations: ['achievement'], + order: { unlockedAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + // Map to activity events + const puzzleEvents: UserActivityEvent[] = puzzleSubmissions.map((sub) => ({ + description: `Completed "${sub.puzzle.title}" puzzle`, + timestamp: sub.createdAt.toISOString(), + })); + const achievementEvents: UserActivityEvent[] = achievements.map((ach) => ({ + description: `Unlocked "${ach.achievement.title}" achievement`, + timestamp: ach.unlockedAt.toISOString(), + })); + + // Merge and sort by timestamp DESC + const allEvents = [...puzzleEvents, ...achievementEvents].sort( + (a, b) => b.timestamp.localeCompare(a.timestamp) + ); + + // Paginate merged events + const start = 0; + const end = limit; + return allEvents.slice(start, end); + } +} \ No newline at end of file diff --git a/src/users/providers/UserActivityService.ts b/src/users/providers/UserActivityService.ts new file mode 100644 index 0000000..60710eb --- /dev/null +++ b/src/users/providers/UserActivityService.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { PuzzleActivityProvider, UserActivityEvent } from './PuzzleActivityProvider'; + +@Injectable() +export class UserActivityService { + constructor(private readonly puzzleActivityProvider: PuzzleActivityProvider) {} + + async getUserActivity(userId: string, page: number, limit: number): Promise { + return this.puzzleActivityProvider.getRecentActivity(userId, page, limit); + } +} \ No newline at end of file diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 984fbec..cb544d0 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -13,11 +13,15 @@ import { CreateGoogleUserProvider } from './providers/googleUserProvider'; import { PaginationModule } from 'src/common/pagination/pagination.module'; import { FindOneByWallet } from './providers/find-one-by-wallet.provider'; import { UpdateUserService } from './providers/update-user.service'; +import { PuzzleActivityProvider } from './providers/PuzzleActivityProvider'; +import { UserActivityService } from './providers/UserActivityService'; +import { PuzzleSubmission } from 'src/puzzle/entities/puzzle-submission.entity'; +import { UserAchievement } from 'src/achievement/entities/user-achievement.entity'; @Module({ imports: [ forwardRef(() => AuthModule), - TypeOrmModule.forFeature([User]), + TypeOrmModule.forFeature([User, PuzzleSubmission, UserAchievement]), PaginationModule, ], controllers: [UsersController], @@ -30,9 +34,10 @@ import { UpdateUserService } from './providers/update-user.service'; DeleteUserService, FindOneByGoogleIdProvider, CreateGoogleUserProvider, - UpdateUserService + UpdateUserService, + PuzzleActivityProvider, + UserActivityService, ], - exports: [UsersService], // Make service reusable + exports: [UsersService, UserActivityService], }) -@Module({}) export class UsersModule {} diff --git a/test/user-activity.e2e-spec.ts b/test/user-activity.e2e-spec.ts new file mode 100644 index 0000000..646913d --- /dev/null +++ b/test/user-activity.e2e-spec.ts @@ -0,0 +1,76 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from './../src/app.module'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../src/users/user.entity'; +import { PuzzleSubmission } from '../src/puzzle/entities/puzzle-submission.entity'; +import { UserAchievement } from '../src/achievement/entities/user-achievement.entity'; + +describe('User Activity (e2e)', () => { + let app: INestApplication; + let userRepo; + let puzzleSubmissionRepo; + let userAchievementRepo; + let userId: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })); + await app.init(); + + userRepo = moduleFixture.get(getRepositoryToken(User)); + puzzleSubmissionRepo = moduleFixture.get(getRepositoryToken(PuzzleSubmission)); + userAchievementRepo = moduleFixture.get(getRepositoryToken(UserAchievement)); + + // Create a test user + const user = userRepo.create({ username: 'testuser', email: 'test@example.com', password: 'testpass' }); + await userRepo.save(user); + userId = user.id; + + // Create a correct puzzle submission + await puzzleSubmissionRepo.save({ + user, + isCorrect: true, + puzzle: { title: 'Binary Tree Maximum Depth' }, + createdAt: new Date('2025-07-05T08:00:00Z'), + }); + + // Create an achievement + await userAchievementRepo.save({ + user, + achievement: { title: 'Code Ninja' }, + unlockedAt: new Date('2025-07-04T16:30:00Z'), + }); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should return recent activity for a user', async () => { + const res = await request(app.getHttpServer()) + .get(`/users/${userId}/activity?page=1&limit=5`) + .expect(200); + expect(res.body.activities).toBeDefined(); + expect(res.body.activities.length).toBeGreaterThan(0); + expect(res.body.activities[0]).toHaveProperty('description'); + expect(res.body.activities[0]).toHaveProperty('timestamp'); + }); + + it('should return 404 for non-existent user', async () => { + await request(app.getHttpServer()) + .get('/users/nonexistentid/activity') + .expect(404); + }); + + it('should validate pagination params', async () => { + await request(app.getHttpServer()) + .get(`/users/${userId}/activity?page=0&limit=0`) + .expect(400); + }); +}); \ No newline at end of file