Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions test/jest-e2e.json β†’ jest-e2e.json
Original file line number Diff line number Diff line change
@@ -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/(.*)$": "<rootDir>/src/$1"
}
}
23 changes: 22 additions & 1 deletion src/users/controllers/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down Expand Up @@ -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<UserActivityResponseDto> {
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 };
}
}
9 changes: 9 additions & 0 deletions src/users/dtos/user-activity-event.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions src/users/dtos/user-activity-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { UserActivityEventDto } from './user-activity-event.dto';

export class UserActivityResponseDto {
@ApiProperty({ type: [UserActivityEventDto] })
activities: UserActivityEventDto[];
}
67 changes: 67 additions & 0 deletions src/users/providers/PuzzleActivityProvider.ts
Original file line number Diff line number Diff line change
@@ -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<PuzzleSubmission>,
@InjectRepository(UserAchievement)
private readonly userAchievementRepo: Repository<UserAchievement>,
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}

async getRecentActivity(userId: string, page = 1, limit = 5): Promise<UserActivityEvent[]> {
// 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);
}
}
11 changes: 11 additions & 0 deletions src/users/providers/UserActivityService.ts
Original file line number Diff line number Diff line change
@@ -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<UserActivityEvent[]> {
return this.puzzleActivityProvider.getRecentActivity(userId, page, limit);
}
}
13 changes: 9 additions & 4 deletions src/users/users.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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 {}
76 changes: 76 additions & 0 deletions test/user-activity.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading