From de69b1abe3aae8f31bce1a373531516cd447eccb Mon Sep 17 00:00:00 2001 From: Ahmed Date: Sun, 27 Sep 2026 18:23:59 +0100 Subject: [PATCH 1/4] feat(community-events): implement community events and special events (#451) --- .../community-events.controller.ts | 170 ++++++ .../community-events.module.ts | 21 + .../community-events.service.spec.ts | 295 ++++++++++ .../community-events.service.ts | 531 ++++++++++++++++++ .../dto/create-community-event.dto.ts | 125 +++++ src/community-events/dto/index.ts | 2 + src/community-events/dto/participation.dto.ts | 65 +++ .../entities/community-event.entity.ts | 116 ++++ .../entities/event-milestone.entity.ts | 64 +++ .../entities/event-participation.entity.ts | 75 +++ src/community-events/entities/index.ts | 3 + 11 files changed, 1467 insertions(+) create mode 100644 src/community-events/community-events.controller.ts create mode 100644 src/community-events/community-events.module.ts create mode 100644 src/community-events/community-events.service.spec.ts create mode 100644 src/community-events/community-events.service.ts create mode 100644 src/community-events/dto/create-community-event.dto.ts create mode 100644 src/community-events/dto/index.ts create mode 100644 src/community-events/dto/participation.dto.ts create mode 100644 src/community-events/entities/community-event.entity.ts create mode 100644 src/community-events/entities/event-milestone.entity.ts create mode 100644 src/community-events/entities/event-participation.entity.ts create mode 100644 src/community-events/entities/index.ts diff --git a/src/community-events/community-events.controller.ts b/src/community-events/community-events.controller.ts new file mode 100644 index 0000000..39da3cf --- /dev/null +++ b/src/community-events/community-events.controller.ts @@ -0,0 +1,170 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + ParseUUIDPipe, + ParseIntPipe, + HttpCode, + HttpStatus, + UseGuards, + DefaultValuePipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { CommunityEventsService } from './community-events.service'; +import { + CreateCommunityEventDto, + RecordParticipationDto, + CreateMilestoneDto, +} from './dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../auth/constants'; + +@ApiTags('Community Events') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('community-events') +export class CommunityEventsController { + constructor(private readonly service: CommunityEventsService) {} + + // ─── Events ──────────────────────────────────────────────────────────────── + + @Post() + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Create a new community event (admin only)' }) + @ApiResponse({ status: 201, description: 'Event created' }) + async create(@Body() dto: CreateCommunityEventDto) { + return this.service.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List all community events' }) + @ApiQuery({ name: 'status', required: false }) + async findAll(@Query('status') status?: string) { + return this.service.findAll(status ? { status: status as any } : {}); + } + + @Get('active') + @ApiOperation({ summary: 'Get all currently active community events' }) + async getActive() { + return this.service.findActive(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single community event by ID' }) + @ApiResponse({ status: 200, description: 'Event found' }) + @ApiResponse({ status: 404, description: 'Event not found' }) + async findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findOne(id); + } + + // ─── Participation ───────────────────────────────────────────────────────── + + @Post(':id/participate') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Record a participation action for a user' }) + @ApiResponse({ status: 200, description: 'Participation recorded' }) + @ApiResponse({ status: 400, description: 'Event not active or at capacity' }) + async recordParticipation( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RecordParticipationDto, + ) { + return this.service.recordParticipation(id, dto); + } + + @Get(':id/participation/:userId') + @ApiOperation({ summary: "Get a user's participation record for an event" }) + async getParticipation( + @Param('id', ParseUUIDPipe) id: string, + @Param('userId') userId: string, + ) { + return this.service.getParticipation(id, userId); + } + + @Post(':id/participation/:userId/verify') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Admin: mark a participation record as verified' }) + async verifyParticipation( + @Param('id', ParseUUIDPipe) id: string, + @Param('userId') userId: string, + ) { + return this.service.verifyParticipation(id, userId); + } + + // ─── Milestones ──────────────────────────────────────────────────────────── + + @Post(':id/milestones') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Add a milestone to an event (admin only)' }) + async addMilestone( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CreateMilestoneDto, + ) { + return this.service.addMilestone(id, dto); + } + + @Get(':id/milestones') + @ApiOperation({ summary: 'Get all milestones for an event' }) + async getMilestones(@Param('id', ParseUUIDPipe) id: string) { + return this.service.getMilestones(id); + } + + // ─── Leaderboard ─────────────────────────────────────────────────────────── + + @Get(':id/leaderboard') + @ApiOperation({ summary: 'Get event leaderboard' }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getLeaderboard( + @Param('id', ParseUUIDPipe) id: string, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ) { + return this.service.getLeaderboard(id, limit); + } + + @Get(':id/leaderboard/:userId') + @ApiOperation({ summary: "Get a user's rank within the event leaderboard" }) + async getUserRank( + @Param('id', ParseUUIDPipe) id: string, + @Param('userId') userId: string, + ) { + return this.service.getUserRank(id, userId); + } + + // ─── Analytics ───────────────────────────────────────────────────────────── + + @Get(':id/analytics') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get engagement analytics for an event (admin only)' }) + async getAnalytics(@Param('id', ParseUUIDPipe) id: string) { + return this.service.getAnalytics(id); + } + + // ─── Reward Distribution ─────────────────────────────────────────────────── + + @Post(':id/distribute-rewards') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Manually trigger end-of-event reward distribution (admin only)' }) + async distributeRewards(@Param('id', ParseUUIDPipe) id: string) { + await this.service.distributeEndRewards(id); + return { message: 'Rewards distributed successfully' }; + } +} diff --git a/src/community-events/community-events.module.ts b/src/community-events/community-events.module.ts new file mode 100644 index 0000000..c965f33 --- /dev/null +++ b/src/community-events/community-events.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; +import { + CommunityEvent, + EventParticipation, + EventMilestone, +} from './entities'; +import { CommunityEventsService } from './community-events.service'; +import { CommunityEventsController } from './community-events.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([CommunityEvent, EventParticipation, EventMilestone]), + ScheduleModule.forRoot(), + ], + controllers: [CommunityEventsController], + providers: [CommunityEventsService], + exports: [CommunityEventsService], +}) +export class CommunityEventsModule {} diff --git a/src/community-events/community-events.service.spec.ts b/src/community-events/community-events.service.spec.ts new file mode 100644 index 0000000..ef4754f --- /dev/null +++ b/src/community-events/community-events.service.spec.ts @@ -0,0 +1,295 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { CommunityEventsService } from './community-events.service'; +import { CommunityEvent, CommunityEventStatus, CommunityEventPhase } from './entities/community-event.entity'; +import { EventParticipation } from './entities/event-participation.entity'; +import { EventMilestone } from './entities/event-milestone.entity'; + +const mockRepo = () => ({ + create: jest.fn((d) => d), + save: jest.fn((d) => Promise.resolve({ ...d, id: d.id ?? 'generated-id' })), + findOne: jest.fn(), + find: jest.fn(), + count: jest.fn(), + increment: jest.fn().mockResolvedValue(undefined), +}); + +const baseEvent = (): CommunityEvent => ({ + id: 'event-1', + name: 'Test Event', + description: 'A test community event', + theme: 'general', + startDate: new Date(Date.now() - 1000), + endDate: new Date(Date.now() + 86400000), + status: CommunityEventStatus.ACTIVE, + currentPhase: CommunityEventPhase.MAIN, + participantCount: 0, + analytics: { totalActionsPerformed: 0, dailyParticipants: {} }, + exclusiveRewards: [], + rewardsDistributed: false, + isPublished: true, + createdAt: new Date(), + updatedAt: new Date(), + participations: [], + milestones: [], +}); + +const baseMilestone = (): EventMilestone => ({ + id: 'milestone-1', + eventId: 'event-1', + name: 'First Steps', + description: 'Complete your first action', + requiredScore: 100, + reward: { type: 'badge', name: 'Trailblazer', value: 0 }, + isActive: true, + claimedCount: 0, + displayOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + event: {} as any, +}); + +describe('CommunityEventsService', () => { + let service: CommunityEventsService; + let eventRepo: ReturnType; + let participationRepo: ReturnType; + let milestoneRepo: ReturnType; + + beforeEach(async () => { + eventRepo = mockRepo(); + participationRepo = mockRepo(); + milestoneRepo = mockRepo(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CommunityEventsService, + { provide: getRepositoryToken(CommunityEvent), useValue: eventRepo }, + { provide: getRepositoryToken(EventParticipation), useValue: participationRepo }, + { provide: getRepositoryToken(EventMilestone), useValue: milestoneRepo }, + ], + }).compile(); + + service = module.get(CommunityEventsService); + }); + + afterEach(() => jest.clearAllMocks()); + + // ─── create ────────────────────────────────────────────────────────────── + + describe('create', () => { + it('creates a scheduled event successfully', async () => { + const dto = { + name: 'Spring Bash', + description: 'Spring community event', + startDate: new Date(Date.now() + 1000), + endDate: new Date(Date.now() + 86400000), + }; + eventRepo.create.mockReturnValue({ ...dto, id: 'new-id' }); + eventRepo.save.mockResolvedValue({ ...dto, id: 'new-id', status: CommunityEventStatus.SCHEDULED }); + + const result = await service.create(dto as any); + expect(result.status).toBe(CommunityEventStatus.SCHEDULED); + }); + + it('rejects when startDate >= endDate', async () => { + const now = new Date(); + await expect( + service.create({ + name: 'Bad Event', + description: 'desc', + startDate: now, + endDate: now, + } as any), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ─── findOne ───────────────────────────────────────────────────────────── + + describe('findOne', () => { + it('returns the event when found', async () => { + eventRepo.findOne.mockResolvedValue(baseEvent()); + const result = await service.findOne('event-1'); + expect(result.id).toBe('event-1'); + }); + + it('throws NotFoundException when event does not exist', async () => { + eventRepo.findOne.mockResolvedValue(null); + await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException); + }); + }); + + // ─── recordParticipation ───────────────────────────────────────────────── + + describe('recordParticipation', () => { + it('creates a new participation record on first join', async () => { + eventRepo.findOne.mockResolvedValue(baseEvent()); + participationRepo.findOne.mockResolvedValue(null); + participationRepo.count.mockResolvedValue(0); + milestoneRepo.find.mockResolvedValue([]); + const savedParticipation: Partial = { + id: 'part-1', + userId: 'user-1', + eventId: 'event-1', + score: 50, + actionsPerformed: 1, + milestonesReached: [], + rewardsEarned: [], + isVerified: true, + joinedAt: new Date(), + lastActivityAt: new Date(), + }; + participationRepo.create.mockReturnValue(savedParticipation); + participationRepo.save.mockResolvedValue(savedParticipation); + + const result = await service.recordParticipation('event-1', { + userId: 'user-1', + score: 50, + }); + expect(result.score).toBe(50); + expect(eventRepo.increment).toHaveBeenCalledWith({ id: 'event-1' }, 'participantCount', 1); + }); + + it('rejects participation on an inactive event', async () => { + const inactiveEvent = { ...baseEvent(), status: CommunityEventStatus.COMPLETED }; + eventRepo.findOne.mockResolvedValue(inactiveEvent); + + await expect( + service.recordParticipation('event-1', { userId: 'user-1', score: 10 }), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects when event is at max capacity', async () => { + const fullEvent = { ...baseEvent(), maxParticipants: 5 }; + eventRepo.findOne.mockResolvedValue(fullEvent); + participationRepo.count.mockResolvedValue(5); + + await expect( + service.recordParticipation('event-1', { userId: 'user-new', score: 10 }), + ).rejects.toThrow(BadRequestException); + }); + + it('awards milestone when score threshold is crossed', async () => { + const existing: Partial = { + id: 'part-1', + userId: 'user-1', + eventId: 'event-1', + score: 50, + actionsPerformed: 1, + milestonesReached: [], + rewardsEarned: [], + isVerified: false, + lastActivityAt: new Date(), + }; + eventRepo.findOne.mockResolvedValue(baseEvent()); + participationRepo.findOne.mockResolvedValueOnce(existing); + milestoneRepo.find.mockResolvedValue([baseMilestone()]); + participationRepo.save.mockImplementation((d) => Promise.resolve(d)); + + // Score goes from 50 → 100 which should trigger the milestone + existing.score = 100; + + await service.recordParticipation('event-1', { userId: 'user-1', score: 50 }); + expect(milestoneRepo.increment).toHaveBeenCalledWith( + { id: 'milestone-1' }, + 'claimedCount', + 1, + ); + }); + }); + + // ─── getLeaderboard ────────────────────────────────────────────────────── + + describe('getLeaderboard', () => { + it('returns ranked entries sorted by score', async () => { + eventRepo.findOne.mockResolvedValue(baseEvent()); + participationRepo.find.mockResolvedValue([ + { userId: 'u1', score: 200, actionsPerformed: 5, milestonesReached: [], lastActivityAt: new Date() }, + { userId: 'u2', score: 100, actionsPerformed: 3, milestonesReached: [], lastActivityAt: new Date() }, + ]); + + const result = await service.getLeaderboard('event-1', 10); + expect(result[0].rank).toBe(1); + expect(result[0].userId).toBe('u1'); + expect(result[1].rank).toBe(2); + }); + }); + + // ─── getAnalytics ──────────────────────────────────────────────────────── + + describe('getAnalytics', () => { + it('calculates analytics correctly', async () => { + const event = { ...baseEvent(), analytics: { totalActionsPerformed: 10, dailyParticipants: { '2025-12-01': 3 } } }; + eventRepo.findOne.mockResolvedValue(event); + participationRepo.find.mockResolvedValue([ + { score: 200, milestonesReached: [{ milestoneId: 'milestone-1' }] }, + { score: 100, milestonesReached: [] }, + ]); + milestoneRepo.find.mockResolvedValue([baseMilestone()]); + + const analytics = await service.getAnalytics('event-1'); + expect(analytics.participantCount).toBe(0); + expect(analytics.averageScore).toBe(150); + expect(analytics.milestoneCompletionRates[0].participantsReached).toBe(1); + expect(analytics.totalActionsPerformed).toBe(10); + }); + }); + + // ─── addMilestone ──────────────────────────────────────────────────────── + + describe('addMilestone', () => { + it('creates a milestone for an existing event', async () => { + eventRepo.findOne.mockResolvedValue(baseEvent()); + const milestone = baseMilestone(); + milestoneRepo.create.mockReturnValue(milestone); + milestoneRepo.save.mockResolvedValue(milestone); + + const result = await service.addMilestone('event-1', { + name: 'First Steps', + requiredScore: 100, + reward: { type: 'badge', name: 'Trailblazer' }, + } as any); + + expect(result.name).toBe('First Steps'); + }); + }); + + // ─── distributeEndRewards ──────────────────────────────────────────────── + + describe('distributeEndRewards', () => { + it('skips distribution when rewards already distributed', async () => { + const event = { ...baseEvent(), rewardsDistributed: true }; + eventRepo.findOne.mockResolvedValue(event); + + await service.distributeEndRewards('event-1'); + expect(participationRepo.find).not.toHaveBeenCalled(); + }); + + it('distributes rewards to top-ranked participants', async () => { + const event = { + ...baseEvent(), + rewardsDistributed: false, + exclusiveRewards: [{ rewardId: 'r1', name: 'Champion Badge', type: 'badge', rank: 1 }], + }; + eventRepo.findOne.mockResolvedValue(event); + + // getLeaderboard calls + participationRepo.find + .mockResolvedValueOnce([ + { userId: 'u1', score: 300, actionsPerformed: 10, milestonesReached: [], lastActivityAt: new Date() }, + ]) + // getParticipation for distribution + .mockResolvedValueOnce([ + { userId: 'u1', score: 300, actionsPerformed: 10, milestonesReached: [], lastActivityAt: new Date() }, + ]); + + const savedPart = { userId: 'u1', eventId: 'event-1', rewardsEarned: [], isVerified: true }; + participationRepo.findOne.mockResolvedValue(savedPart); + participationRepo.save.mockResolvedValue(savedPart); + + await service.distributeEndRewards('event-1'); + expect(participationRepo.save).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/community-events/community-events.service.ts b/src/community-events/community-events.service.ts new file mode 100644 index 0000000..03019fc --- /dev/null +++ b/src/community-events/community-events.service.ts @@ -0,0 +1,531 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan, MoreThan, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { + CommunityEvent, + CommunityEventStatus, + CommunityEventPhase, +} from './entities/community-event.entity'; +import { EventParticipation } from './entities/event-participation.entity'; +import { EventMilestone } from './entities/event-milestone.entity'; +import { CreateCommunityEventDto } from './dto/create-community-event.dto'; +import { RecordParticipationDto, CreateMilestoneDto } from './dto/participation.dto'; + +export interface EventLeaderboardEntry { + rank: number; + userId: string; + score: number; + actionsPerformed: number; + milestonesReached: number; + lastActivityAt: Date | undefined; +} + +export interface EventAnalytics { + eventId: string; + participantCount: number; + totalActionsPerformed: number; + averageScore: number; + milestoneCompletionRates: Array<{ + milestoneId: string; + milestoneName: string; + requiredScore: number; + participantsReached: number; + completionRate: number; + }>; + topParticipants: Array<{ userId: string; score: number }>; + dailyEngagement: Record; +} + +@Injectable() +export class CommunityEventsService { + private readonly logger = new Logger(CommunityEventsService.name); + + constructor( + @InjectRepository(CommunityEvent) + private readonly eventRepository: Repository, + @InjectRepository(EventParticipation) + private readonly participationRepository: Repository, + @InjectRepository(EventMilestone) + private readonly milestoneRepository: Repository, + ) {} + + // ─── Event Scheduling ────────────────────────────────────────────────────── + + /** + * Cron: activate/deactivate events every 5 minutes and advance phases. + */ + @Cron(CronExpression.EVERY_5_MINUTES) + async handleEventScheduling(): Promise { + this.logger.log('Running community event scheduling cron job'); + const now = new Date(); + + try { + await this.activateScheduledEvents(now); + await this.deactivateExpiredEvents(now); + await this.advanceEventPhases(now); + } catch (error) { + this.logger.error('Error in event scheduling cron', (error as Error).stack); + } + } + + private async activateScheduledEvents(now: Date): Promise { + const events = await this.eventRepository.find({ + where: { + status: CommunityEventStatus.SCHEDULED, + isPublished: true, + startDate: LessThanOrEqual(now), + endDate: MoreThan(now), + }, + }); + + for (const event of events) { + event.status = CommunityEventStatus.ACTIVE; + event.currentPhase = CommunityEventPhase.MAIN; + await this.eventRepository.save(event); + this.logger.log(`Activated community event: ${event.name} (${event.id})`); + } + } + + private async deactivateExpiredEvents(now: Date): Promise { + const events = await this.eventRepository.find({ + where: { + status: CommunityEventStatus.ACTIVE, + endDate: LessThan(now), + }, + }); + + for (const event of events) { + event.status = CommunityEventStatus.COMPLETED; + event.currentPhase = CommunityEventPhase.ENDED; + await this.eventRepository.save(event); + this.logger.log(`Completed community event: ${event.name} (${event.id})`); + await this.distributeEndRewards(event.id); + } + } + + private async advanceEventPhases(now: Date): Promise { + const activeEvents = await this.eventRepository.find({ + where: { status: CommunityEventStatus.ACTIVE }, + }); + + for (const event of activeEvents) { + if (!event.phases?.length) continue; + + const currentPhaseConfig = event.phases.find( + (p) => + new Date(p.startDate) <= now && new Date(p.endDate) >= now, + ); + + if ( + currentPhaseConfig && + currentPhaseConfig.phase !== event.currentPhase + ) { + event.currentPhase = currentPhaseConfig.phase; + await this.eventRepository.save(event); + this.logger.log( + `Advanced event ${event.id} to phase: ${currentPhaseConfig.phase}`, + ); + } + } + } + + // ─── CRUD ────────────────────────────────────────────────────────────────── + + async create(dto: CreateCommunityEventDto): Promise { + if (dto.startDate >= dto.endDate) { + throw new BadRequestException('startDate must be before endDate'); + } + + const event = this.eventRepository.create({ + ...dto, + status: CommunityEventStatus.SCHEDULED, + currentPhase: CommunityEventPhase.PRE_EVENT, + }); + + return this.eventRepository.save(event); + } + + async findAll(filters: { status?: CommunityEventStatus } = {}): Promise { + return this.eventRepository.find({ where: filters }); + } + + async findOne(id: string): Promise { + const event = await this.eventRepository.findOne({ + where: { id }, + relations: ['milestones'], + }); + if (!event) { + throw new NotFoundException(`Community event ${id} not found`); + } + return event; + } + + async findActive(): Promise { + return this.eventRepository.find({ + where: { status: CommunityEventStatus.ACTIVE, isPublished: true }, + }); + } + + // ─── Participation ───────────────────────────────────────────────────────── + + /** + * Record a participation action for a user. + * Creates participant record on first join, updates score afterwards. + * Checks milestones and grants rewards. + */ + async recordParticipation( + eventId: string, + dto: RecordParticipationDto, + ): Promise { + const event = await this.findOne(eventId); + + if (event.status !== CommunityEventStatus.ACTIVE) { + throw new BadRequestException('Community event is not currently active'); + } + + if ( + event.maxParticipants !== null && + event.maxParticipants !== undefined + ) { + const count = await this.participationRepository.count({ + where: { eventId }, + }); + if (count >= event.maxParticipants) { + throw new BadRequestException('Event has reached its maximum number of participants'); + } + } + + let participation = await this.participationRepository.findOne({ + where: { userId: dto.userId, eventId }, + }); + + const isFirstJoin = !participation; + + if (!participation) { + participation = this.participationRepository.create({ + userId: dto.userId, + eventId, + joinedAt: new Date(), + lastActivityAt: new Date(), + isVerified: false, + }); + + // Increment event participant counter + await this.eventRepository.increment({ id: eventId }, 'participantCount', 1); + } + + participation.score += dto.score; + participation.actionsPerformed += 1; + participation.lastActivityAt = new Date(); + + if (dto.actionMetadata) { + participation.verificationData = { + ...(participation.verificationData ?? {}), + ...dto.actionMetadata, + }; + } + + participation = await this.participationRepository.save(participation); + + // Update daily analytics + await this.updateAnalytics(eventId, dto.userId, isFirstJoin); + + // Check and award milestones + await this.checkMilestones(participation, eventId); + + // Mark as verified once the user has taken at least one action + if (!participation.isVerified) { + participation.isVerified = true; + participation = await this.participationRepository.save(participation); + } + + return participation; + } + + private async checkMilestones( + participation: EventParticipation, + eventId: string, + ): Promise { + const milestones = await this.milestoneRepository.find({ + where: { eventId, isActive: true }, + order: { requiredScore: 'ASC' }, + }); + + for (const milestone of milestones) { + // Skip if already reached + const alreadyReached = participation.milestonesReached.some( + (m) => m.milestoneId === milestone.id, + ); + if (alreadyReached) continue; + + // Skip if score threshold not yet met + if (participation.score < milestone.requiredScore) continue; + + // Skip if reward cap exhausted + if ( + milestone.maxClaims !== null && + milestone.maxClaims !== undefined && + milestone.claimedCount >= milestone.maxClaims + ) { + continue; + } + + // Award milestone + participation.milestonesReached.push({ + milestoneId: milestone.id, + milestoneName: milestone.name, + reachedAt: new Date(), + }); + + participation.rewardsEarned.push({ + rewardId: milestone.id, + rewardName: milestone.reward.name, + rewardType: milestone.reward.type, + earnedAt: new Date(), + }); + + await this.milestoneRepository.increment({ id: milestone.id }, 'claimedCount', 1); + this.logger.log( + `Milestone "${milestone.name}" reached by user ${participation.userId}`, + ); + } + + await this.participationRepository.save(participation); + } + + private async updateAnalytics( + eventId: string, + _userId: string, + isFirstJoin: boolean, + ): Promise { + const event = await this.eventRepository.findOne({ where: { id: eventId } }); + if (!event) return; + + const today = new Date().toISOString().split('T')[0]; + const dailyParticipants = event.analytics.dailyParticipants ?? {}; + dailyParticipants[today] = (dailyParticipants[today] ?? 0) + (isFirstJoin ? 1 : 0); + + const totalActions = (event.analytics.totalActionsPerformed ?? 0) + 1; + + event.analytics = { + ...event.analytics, + totalActionsPerformed: totalActions, + dailyParticipants, + }; + + await this.eventRepository.save(event); + } + + // ─── Milestones ──────────────────────────────────────────────────────────── + + async addMilestone( + eventId: string, + dto: CreateMilestoneDto, + ): Promise { + await this.findOne(eventId); // Validate event exists + + const milestone = this.milestoneRepository.create({ + ...dto, + eventId, + displayOrder: dto.displayOrder ?? 0, + }); + + return this.milestoneRepository.save(milestone); + } + + async getMilestones(eventId: string): Promise { + await this.findOne(eventId); + return this.milestoneRepository.find({ + where: { eventId }, + order: { displayOrder: 'ASC', requiredScore: 'ASC' }, + }); + } + + // ─── Leaderboard ─────────────────────────────────────────────────────────── + + async getLeaderboard( + eventId: string, + limit: number = 10, + ): Promise { + await this.findOne(eventId); + + const participations = await this.participationRepository.find({ + where: { eventId }, + order: { score: 'DESC', actionsPerformed: 'DESC', lastActivityAt: 'ASC' }, + take: limit, + }); + + return participations.map((p, index) => ({ + rank: index + 1, + userId: p.userId, + score: p.score, + actionsPerformed: p.actionsPerformed, + milestonesReached: p.milestonesReached.length, + lastActivityAt: p.lastActivityAt, + })); + } + + async getUserRank( + eventId: string, + userId: string, + ): Promise<{ rank: number | null; participation: EventParticipation | null }> { + await this.findOne(eventId); + + const participation = await this.participationRepository.findOne({ + where: { userId, eventId }, + }); + + if (!participation) { + return { rank: null, participation: null }; + } + + const higher = await this.participationRepository.count({ + where: [ + { eventId, score: MoreThan(participation.score) }, + ], + }); + + return { rank: higher + 1, participation }; + } + + // ─── Reward Distribution ─────────────────────────────────────────────────── + + async distributeEndRewards(eventId: string): Promise { + const event = await this.eventRepository.findOne({ where: { id: eventId } }); + if (!event || event.rewardsDistributed) return; + + if (!event.exclusiveRewards?.length) { + event.rewardsDistributed = true; + await this.eventRepository.save(event); + return; + } + + const leaderboard = await this.getLeaderboard(eventId, 100); + + for (const reward of event.exclusiveRewards) { + for (const entry of leaderboard) { + const qualifiesByRank = reward.rank !== undefined && entry.rank <= reward.rank; + const qualifiesByScore = reward.minScore !== undefined && entry.score >= reward.minScore; + + if (!qualifiesByRank && !qualifiesByScore) continue; + + const participation = await this.participationRepository.findOne({ + where: { userId: entry.userId, eventId }, + }); + + if (!participation) continue; + + const alreadyEarned = participation.rewardsEarned.some( + (r) => r.rewardId === reward.rewardId, + ); + + if (!alreadyEarned) { + participation.rewardsEarned.push({ + rewardId: reward.rewardId, + rewardName: reward.name, + rewardType: reward.type, + earnedAt: new Date(), + }); + await this.participationRepository.save(participation); + this.logger.log( + `Distributed end-reward "${reward.name}" to user ${entry.userId} for event ${eventId}`, + ); + } + } + } + + event.rewardsDistributed = true; + await this.eventRepository.save(event); + } + + // ─── Analytics ───────────────────────────────────────────────────────────── + + async getAnalytics(eventId: string): Promise { + const event = await this.findOne(eventId); + + const participations = await this.participationRepository.find({ + where: { eventId }, + }); + + const milestones = await this.milestoneRepository.find({ + where: { eventId }, + }); + + const totalScore = participations.reduce((sum, p) => sum + p.score, 0); + const averageScore = participations.length ? totalScore / participations.length : 0; + + const milestoneCompletionRates = milestones.map((m) => { + const participantsReached = participations.filter((p) => + p.milestonesReached.some((mr) => mr.milestoneId === m.id), + ).length; + + return { + milestoneId: m.id, + milestoneName: m.name, + requiredScore: m.requiredScore, + participantsReached, + completionRate: participations.length + ? participantsReached / participations.length + : 0, + }; + }); + + const topParticipants = participations + .sort((a, b) => b.score - a.score) + .slice(0, 5) + .map((p) => ({ userId: p.userId, score: p.score })); + + return { + eventId, + participantCount: event.participantCount, + totalActionsPerformed: event.analytics.totalActionsPerformed ?? 0, + averageScore, + milestoneCompletionRates, + topParticipants, + dailyEngagement: event.analytics.dailyParticipants ?? {}, + }; + } + + // ─── Participation Verification ──────────────────────────────────────────── + + async verifyParticipation( + eventId: string, + userId: string, + ): Promise { + const participation = await this.participationRepository.findOne({ + where: { userId, eventId }, + }); + + if (!participation) { + throw new NotFoundException( + `No participation record for user ${userId} in event ${eventId}`, + ); + } + + participation.isVerified = true; + return this.participationRepository.save(participation); + } + + async getParticipation( + eventId: string, + userId: string, + ): Promise { + const participation = await this.participationRepository.findOne({ + where: { userId, eventId }, + }); + + if (!participation) { + throw new NotFoundException( + `No participation record for user ${userId} in event ${eventId}`, + ); + } + + return participation; + } +} diff --git a/src/community-events/dto/create-community-event.dto.ts b/src/community-events/dto/create-community-event.dto.ts new file mode 100644 index 0000000..ed29eb8 --- /dev/null +++ b/src/community-events/dto/create-community-event.dto.ts @@ -0,0 +1,125 @@ +import { + IsString, + IsNotEmpty, + IsDate, + IsOptional, + IsBoolean, + IsArray, + IsInt, + Min, + ValidateNested, + IsObject, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { CommunityEventPhase } from '../entities/community-event.entity'; + +class EventPhaseDto { + @ApiProperty({ enum: CommunityEventPhase }) + phase: CommunityEventPhase; + + @ApiProperty() + @IsDate() + @Type(() => Date) + startDate: Date; + + @ApiProperty() + @IsDate() + @Type(() => Date) + endDate: Date; + + @ApiPropertyOptional() + @IsOptional() + bonusMultiplier?: number; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + description?: string; +} + +class ExclusiveRewardDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + rewardId: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + type: string; + + @ApiPropertyOptional() + @IsInt() + @Min(0) + @IsOptional() + minScore?: number; + + @ApiPropertyOptional() + @IsInt() + @Min(1) + @IsOptional() + rank?: number; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + description?: string; +} + +export class CreateCommunityEventDto { + @ApiProperty({ description: 'Event name' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ description: 'Event description' }) + @IsString() + @IsNotEmpty() + description: string; + + @ApiPropertyOptional({ description: 'Visual theme of the event' }) + @IsString() + @IsOptional() + theme?: string; + + @ApiProperty({ description: 'Event start date' }) + @IsDate() + @Type(() => Date) + startDate: Date; + + @ApiProperty({ description: 'Event end date' }) + @IsDate() + @Type(() => Date) + endDate: Date; + + @ApiPropertyOptional({ description: 'Maximum number of participants' }) + @IsInt() + @Min(1) + @IsOptional() + maxParticipants?: number; + + @ApiPropertyOptional({ description: 'Event phases configuration' }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EventPhaseDto) + @IsOptional() + phases?: EventPhaseDto[]; + + @ApiPropertyOptional({ description: 'Exclusive rewards for the event' }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ExclusiveRewardDto) + @IsOptional() + exclusiveRewards?: ExclusiveRewardDto[]; + + @ApiPropertyOptional({ default: true }) + @IsBoolean() + @IsOptional() + isPublished?: boolean; +} diff --git a/src/community-events/dto/index.ts b/src/community-events/dto/index.ts new file mode 100644 index 0000000..7683d7c --- /dev/null +++ b/src/community-events/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-community-event.dto'; +export * from './participation.dto'; diff --git a/src/community-events/dto/participation.dto.ts b/src/community-events/dto/participation.dto.ts new file mode 100644 index 0000000..b28b880 --- /dev/null +++ b/src/community-events/dto/participation.dto.ts @@ -0,0 +1,65 @@ +import { + IsString, + IsNotEmpty, + IsInt, + Min, + IsOptional, + IsObject, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class RecordParticipationDto { + @ApiProperty({ description: 'Player / user ID performing the action' }) + @IsString() + @IsNotEmpty() + userId: string; + + @ApiProperty({ description: 'Points to award for the action', minimum: 0 }) + @IsInt() + @Min(0) + score: number; + + @ApiPropertyOptional({ description: 'Arbitrary action metadata' }) + @IsObject() + @IsOptional() + actionMetadata?: Record; +} + +export class CreateMilestoneDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + description?: string; + + @ApiProperty({ description: 'Score threshold to unlock this milestone' }) + @IsInt() + @Min(0) + requiredScore: number; + + @ApiProperty({ description: 'Reward granted when this milestone is reached' }) + @IsObject() + reward: { + type: 'points' | 'badge' | 'item' | 'currency' | 'title'; + value?: number; + name: string; + description?: string; + metadata?: Record; + }; + + @ApiPropertyOptional() + @IsInt() + @Min(1) + @IsOptional() + maxClaims?: number; + + @ApiPropertyOptional() + @IsInt() + @Min(0) + @IsOptional() + displayOrder?: number; +} diff --git a/src/community-events/entities/community-event.entity.ts b/src/community-events/entities/community-event.entity.ts new file mode 100644 index 0000000..c7047be --- /dev/null +++ b/src/community-events/entities/community-event.entity.ts @@ -0,0 +1,116 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { EventParticipation } from './event-participation.entity'; +import { EventMilestone } from './event-milestone.entity'; + +export enum CommunityEventStatus { + SCHEDULED = 'scheduled', + ACTIVE = 'active', + COMPLETED = 'completed', + CANCELLED = 'cancelled', +} + +export enum CommunityEventPhase { + PRE_EVENT = 'pre_event', + MAIN = 'main', + FINAL_PUSH = 'final_push', + ENDED = 'ended', +} + +@Entity('community_events') +@Index(['status']) +@Index(['startDate', 'endDate']) +export class CommunityEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 200 }) + @Index() + name: string; + + @Column({ type: 'text' }) + description: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + theme?: string; + + @Column({ type: 'timestamp with time zone' }) + @Index() + startDate: Date; + + @Column({ type: 'timestamp with time zone' }) + @Index() + endDate: Date; + + @Column({ + type: 'enum', + enum: CommunityEventStatus, + default: CommunityEventStatus.SCHEDULED, + }) + @Index() + status: CommunityEventStatus; + + @Column({ + type: 'enum', + enum: CommunityEventPhase, + default: CommunityEventPhase.PRE_EVENT, + }) + currentPhase: CommunityEventPhase; + + @Column({ type: 'jsonb', nullable: true }) + phases?: Array<{ + phase: CommunityEventPhase; + startDate: Date; + endDate: Date; + bonusMultiplier?: number; + description?: string; + }>; + + @Column({ type: 'int', default: 0 }) + participantCount: number; + + @Column({ type: 'int', nullable: true }) + maxParticipants?: number; + + @Column({ type: 'jsonb', default: {} }) + analytics: { + totalActionsPerformed?: number; + dailyParticipants?: Record; + engagementScore?: number; + }; + + @Column({ type: 'jsonb', nullable: true }) + exclusiveRewards?: Array<{ + rewardId: string; + name: string; + type: string; + minScore?: number; + rank?: number; + description?: string; + }>; + + @Column({ type: 'boolean', default: false }) + rewardsDistributed: boolean; + + @Column({ type: 'boolean', default: true }) + isPublished: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @OneToMany(() => EventParticipation, (p) => p.event, { cascade: true }) + participations: EventParticipation[]; + + @OneToMany(() => EventMilestone, (m) => m.event, { cascade: true }) + milestones: EventMilestone[]; +} diff --git a/src/community-events/entities/event-milestone.entity.ts b/src/community-events/entities/event-milestone.entity.ts new file mode 100644 index 0000000..a6c3442 --- /dev/null +++ b/src/community-events/entities/event-milestone.entity.ts @@ -0,0 +1,64 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { CommunityEvent } from './community-event.entity'; + +@Entity('event_milestones') +@Index(['eventId', 'requiredScore']) +export class EventMilestone { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + eventId: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'int' }) + requiredScore: number; + + @Column({ type: 'jsonb' }) + reward: { + type: 'points' | 'badge' | 'item' | 'currency' | 'title'; + value?: number; + name: string; + description?: string; + metadata?: Record; + }; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'int', default: 0 }) + claimedCount: number; + + @Column({ type: 'int', nullable: true }) + maxClaims?: number; + + @Column({ type: 'int', default: 0 }) + displayOrder: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @ManyToOne(() => CommunityEvent, (event) => event.milestones, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'eventId' }) + event: CommunityEvent; +} diff --git a/src/community-events/entities/event-participation.entity.ts b/src/community-events/entities/event-participation.entity.ts new file mode 100644 index 0000000..33e4475 --- /dev/null +++ b/src/community-events/entities/event-participation.entity.ts @@ -0,0 +1,75 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, + Unique, +} from 'typeorm'; +import { CommunityEvent } from './community-event.entity'; + +@Entity('event_participations') +@Index(['userId', 'eventId']) +@Index(['eventId', 'score']) +@Unique(['userId', 'eventId']) +export class EventParticipation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + userId: string; + + @Column({ type: 'uuid' }) + @Index() + eventId: string; + + @Column({ type: 'int', default: 0 }) + @Index() + score: number; + + @Column({ type: 'int', default: 0 }) + actionsPerformed: number; + + @Column({ type: 'jsonb', default: [] }) + milestonesReached: Array<{ + milestoneId: string; + milestoneName: string; + reachedAt: Date; + }>; + + @Column({ type: 'jsonb', default: [] }) + rewardsEarned: Array<{ + rewardId: string; + rewardName: string; + rewardType: string; + earnedAt: Date; + }>; + + @Column({ type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ type: 'jsonb', nullable: true }) + verificationData?: Record; + + @Column({ type: 'timestamp with time zone', nullable: true }) + joinedAt?: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + lastActivityAt?: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @ManyToOne(() => CommunityEvent, (event) => event.participations, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'eventId' }) + event: CommunityEvent; +} diff --git a/src/community-events/entities/index.ts b/src/community-events/entities/index.ts new file mode 100644 index 0000000..a9d0cb0 --- /dev/null +++ b/src/community-events/entities/index.ts @@ -0,0 +1,3 @@ +export * from './community-event.entity'; +export * from './event-participation.entity'; +export * from './event-milestone.entity'; From 98f14f0f136142cb1c0c073040332a8b5b945232 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Sun, 27 Sep 2026 18:25:29 +0100 Subject: [PATCH 2/4] feat(inventory): implement player inventory and item management (#452) --- src/inventory/dto/index.ts | 1 + src/inventory/dto/inventory.dto.ts | 83 +++++ src/inventory/entities/index.ts | 2 + .../entities/inventory-item.entity.ts | 86 ++++++ .../entities/player-inventory.entity.ts | 53 ++++ src/inventory/inventory.controller.ts | 116 +++++++ src/inventory/inventory.module.ts | 13 + src/inventory/inventory.service.spec.ts | 244 +++++++++++++++ src/inventory/inventory.service.ts | 286 ++++++++++++++++++ 9 files changed, 884 insertions(+) create mode 100644 src/inventory/dto/index.ts create mode 100644 src/inventory/dto/inventory.dto.ts create mode 100644 src/inventory/entities/index.ts create mode 100644 src/inventory/entities/inventory-item.entity.ts create mode 100644 src/inventory/entities/player-inventory.entity.ts create mode 100644 src/inventory/inventory.controller.ts create mode 100644 src/inventory/inventory.module.ts create mode 100644 src/inventory/inventory.service.spec.ts create mode 100644 src/inventory/inventory.service.ts diff --git a/src/inventory/dto/index.ts b/src/inventory/dto/index.ts new file mode 100644 index 0000000..4e88540 --- /dev/null +++ b/src/inventory/dto/index.ts @@ -0,0 +1 @@ +export * from './inventory.dto'; diff --git a/src/inventory/dto/inventory.dto.ts b/src/inventory/dto/inventory.dto.ts new file mode 100644 index 0000000..f965c1a --- /dev/null +++ b/src/inventory/dto/inventory.dto.ts @@ -0,0 +1,83 @@ +import { + IsString, + IsNotEmpty, + IsEnum, + IsOptional, + IsInt, + Min, + IsObject, + IsUUID, + IsDate, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ItemCategory, ItemRarity } from '../entities'; + +export class AddItemDto { + @ApiProperty({ description: 'External item reference ID' }) + @IsUUID() + itemId: string; + + @ApiProperty({ description: 'Display name of the item' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + description?: string; + + @ApiProperty({ enum: ItemCategory }) + @IsEnum(ItemCategory) + category: ItemCategory; + + @ApiProperty({ enum: ItemRarity }) + @IsEnum(ItemRarity) + rarity: ItemRarity; + + @ApiPropertyOptional({ minimum: 1, default: 1 }) + @IsInt() + @Min(1) + @IsOptional() + quantity?: number; + + @ApiPropertyOptional() + @IsInt() + @Min(1) + @IsOptional() + maxStack?: number; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + metadata?: Record; + + @ApiPropertyOptional() + @IsDate() + @Type(() => Date) + @IsOptional() + expiresAt?: Date; +} + +export class FilterInventoryDto { + @ApiPropertyOptional({ enum: ItemCategory }) + @IsEnum(ItemCategory) + @IsOptional() + category?: ItemCategory; + + @ApiPropertyOptional({ enum: ItemRarity }) + @IsEnum(ItemRarity) + @IsOptional() + rarity?: ItemRarity; + + @ApiPropertyOptional({ description: 'Sort field: name | rarity | acquiredAt | quantity' }) + @IsString() + @IsOptional() + sortBy?: 'name' | 'rarity' | 'acquiredAt' | 'quantity'; + + @ApiPropertyOptional({ enum: ['ASC', 'DESC'] }) + @IsString() + @IsOptional() + order?: 'ASC' | 'DESC'; +} diff --git a/src/inventory/entities/index.ts b/src/inventory/entities/index.ts new file mode 100644 index 0000000..566b481 --- /dev/null +++ b/src/inventory/entities/index.ts @@ -0,0 +1,2 @@ +export * from './player-inventory.entity'; +export * from './inventory-item.entity'; diff --git a/src/inventory/entities/inventory-item.entity.ts b/src/inventory/entities/inventory-item.entity.ts new file mode 100644 index 0000000..a1b951b --- /dev/null +++ b/src/inventory/entities/inventory-item.entity.ts @@ -0,0 +1,86 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { PlayerInventory, ItemCategory } from './player-inventory.entity'; + +export enum ItemRarity { + COMMON = 'common', + UNCOMMON = 'uncommon', + RARE = 'rare', + EPIC = 'epic', + LEGENDARY = 'legendary', +} + +@Entity('inventory_items') +@Index(['inventoryId', 'category']) +@Index(['inventoryId', 'rarity']) +@Index(['itemId']) +export class InventoryItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + inventoryId: string; + + @Column({ type: 'uuid' }) + @Index() + itemId: string; // External item reference + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ + type: 'enum', + enum: ItemCategory, + default: ItemCategory.COLLECTIBLE, + }) + @Index() + category: ItemCategory; + + @Column({ + type: 'enum', + enum: ItemRarity, + default: ItemRarity.COMMON, + }) + @Index() + rarity: ItemRarity; + + @Column({ type: 'int', default: 1 }) + quantity: number; // For stackable items + + @Column({ type: 'int', nullable: true }) + maxStack?: number; // null = unlimited stacking + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; // Arbitrary item data + + @Column({ type: 'boolean', default: false }) + isEquipped: boolean; + + @Column({ type: 'timestamp with time zone', nullable: true }) + acquiredAt?: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + expiresAt?: Date; // null = never expires + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @ManyToOne(() => PlayerInventory, (inv) => inv.items, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'inventoryId' }) + inventory: PlayerInventory; +} diff --git a/src/inventory/entities/player-inventory.entity.ts b/src/inventory/entities/player-inventory.entity.ts new file mode 100644 index 0000000..c96aa2d --- /dev/null +++ b/src/inventory/entities/player-inventory.entity.ts @@ -0,0 +1,53 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, + Unique, +} from 'typeorm'; +import { InventoryItem } from './inventory-item.entity'; + +export enum ItemCategory { + ACHIEVEMENT = 'achievement', + COLLECTIBLE = 'collectible', + CONSUMABLE = 'consumable', + COSMETIC = 'cosmetic', + CURRENCY = 'currency', + EQUIPMENT = 'equipment', +} + +@Entity('player_inventories') +@Unique(['userId']) +export class PlayerInventory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + userId: string; + + @Column({ type: 'int', default: 100 }) + maxSlots: number; + + @Column({ type: 'int', default: 0 }) + usedSlots: number; + + @Column({ type: 'jsonb', default: {} }) + analytics: { + totalItemsEverAcquired?: number; + categoryBreakdown?: Record; + mostPopularCategory?: string; + }; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @OneToMany(() => InventoryItem, (item) => item.inventory, { cascade: true }) + items: InventoryItem[]; +} diff --git a/src/inventory/inventory.controller.ts b/src/inventory/inventory.controller.ts new file mode 100644 index 0000000..78e8d2d --- /dev/null +++ b/src/inventory/inventory.controller.ts @@ -0,0 +1,116 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + ParseIntPipe, + HttpCode, + HttpStatus, + UseGuards, + DefaultValuePipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { InventoryService } from './inventory.service'; +import { AddItemDto, FilterInventoryDto } from './dto'; +import { ItemCategory, ItemRarity } from './entities'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { ActiveUser } from '../auth/decorators/active-user.decorator'; + +@ApiTags('Player Inventory') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('inventory') +export class InventoryController { + constructor(private readonly inventoryService: InventoryService) {} + + // ─── Inventory ───────────────────────────────────────────────────────────── + + @Get() + @ApiOperation({ summary: "Get the authenticated user's full inventory" }) + async getInventory(@ActiveUser() user: any) { + return this.inventoryService.getInventory(user.id ?? user.sub); + } + + @Get('analytics') + @ApiOperation({ summary: 'Get inventory analytics for the authenticated user' }) + async getAnalytics(@ActiveUser() user: any) { + return this.inventoryService.getAnalytics(user.id ?? user.sub); + } + + // ─── Items ───────────────────────────────────────────────────────────────── + + @Get('items') + @ApiOperation({ summary: 'List inventory items with optional filtering and sorting' }) + @ApiQuery({ name: 'category', required: false, enum: ItemCategory }) + @ApiQuery({ name: 'rarity', required: false, enum: ItemRarity }) + @ApiQuery({ name: 'sortBy', required: false }) + @ApiQuery({ name: 'order', required: false, enum: ['ASC', 'DESC'] }) + async getItems( + @ActiveUser() user: any, + @Query() filters: FilterInventoryDto, + ) { + return this.inventoryService.getItems(user.id ?? user.sub, filters); + } + + @Get('items/:category') + @ApiOperation({ summary: 'Get all items in a specific category' }) + async getItemsByCategory( + @ActiveUser() user: any, + @Param('category') category: ItemCategory, + ) { + return this.inventoryService.getItemsByCategory(user.id ?? user.sub, category); + } + + @Get('item/:itemId') + @ApiOperation({ summary: 'Get a single inventory item by ID' }) + async getItem( + @ActiveUser() user: any, + @Param('itemId', ParseUUIDPipe) itemId: string, + ) { + return this.inventoryService.getItem(user.id ?? user.sub, itemId); + } + + @Post('items') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Add an item to the inventory' }) + @ApiResponse({ status: 201, description: 'Item added' }) + @ApiResponse({ status: 400, description: 'Inventory full or stack limit exceeded' }) + async addItem(@ActiveUser() user: any, @Body() dto: AddItemDto) { + return this.inventoryService.addItem(user.id ?? user.sub, dto); + } + + @Delete('items/:itemId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Remove one or more of an item from the inventory' }) + @ApiQuery({ name: 'quantity', required: false, type: Number }) + async removeItem( + @ActiveUser() user: any, + @Param('itemId', ParseUUIDPipe) itemId: string, + @Query('quantity', new DefaultValuePipe(1), ParseIntPipe) quantity: number, + ) { + await this.inventoryService.removeItem(user.id ?? user.sub, itemId, quantity); + return { message: 'Item removed successfully' }; + } + + // ─── Slot Management ─────────────────────────────────────────────────────── + + @Post('slots') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Update the maximum slot limit for the inventory' }) + async updateSlotLimit( + @ActiveUser() user: any, + @Body('maxSlots', ParseIntPipe) maxSlots: number, + ) { + return this.inventoryService.updateSlotLimit(user.id ?? user.sub, maxSlots); + } +} diff --git a/src/inventory/inventory.module.ts b/src/inventory/inventory.module.ts new file mode 100644 index 0000000..a310ba3 --- /dev/null +++ b/src/inventory/inventory.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PlayerInventory, InventoryItem } from './entities'; +import { InventoryService } from './inventory.service'; +import { InventoryController } from './inventory.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([PlayerInventory, InventoryItem])], + controllers: [InventoryController], + providers: [InventoryService], + exports: [InventoryService], +}) +export class InventoryModule {} diff --git a/src/inventory/inventory.service.spec.ts b/src/inventory/inventory.service.spec.ts new file mode 100644 index 0000000..5f9c096 --- /dev/null +++ b/src/inventory/inventory.service.spec.ts @@ -0,0 +1,244 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { InventoryService } from './inventory.service'; +import { PlayerInventory, ItemCategory } from './entities/player-inventory.entity'; +import { InventoryItem, ItemRarity } from './entities/inventory-item.entity'; + +const mockRepo = () => ({ + create: jest.fn((d) => d), + save: jest.fn((d) => Promise.resolve({ ...d, id: d.id ?? 'generated-id' })), + findOne: jest.fn(), + find: jest.fn(), + remove: jest.fn().mockResolvedValue(undefined), +}); + +const baseInventory = (): PlayerInventory => ({ + id: 'inv-1', + userId: 'user-1', + maxSlots: 100, + usedSlots: 0, + analytics: {}, + createdAt: new Date(), + updatedAt: new Date(), + items: [], +}); + +const baseItem = (): InventoryItem => ({ + id: 'item-1', + inventoryId: 'inv-1', + itemId: 'ref-item-1', + name: 'Fire Badge', + category: ItemCategory.ACHIEVEMENT, + rarity: ItemRarity.RARE, + quantity: 1, + isEquipped: false, + acquiredAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + inventory: {} as any, +}); + +describe('InventoryService', () => { + let service: InventoryService; + let inventoryRepo: ReturnType; + let itemRepo: ReturnType; + + beforeEach(async () => { + inventoryRepo = mockRepo(); + itemRepo = mockRepo(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + InventoryService, + { provide: getRepositoryToken(PlayerInventory), useValue: inventoryRepo }, + { provide: getRepositoryToken(InventoryItem), useValue: itemRepo }, + ], + }).compile(); + + service = module.get(InventoryService); + }); + + afterEach(() => jest.clearAllMocks()); + + // ─── getOrCreate ───────────────────────────────────────────────────────── + + describe('getOrCreate', () => { + it('returns existing inventory', async () => { + inventoryRepo.findOne.mockResolvedValue(baseInventory()); + const result = await service.getOrCreate('user-1'); + expect(result.userId).toBe('user-1'); + }); + + it('creates inventory if none exists', async () => { + inventoryRepo.findOne.mockResolvedValue(null); + inventoryRepo.create.mockReturnValue(baseInventory()); + inventoryRepo.save.mockResolvedValue(baseInventory()); + const result = await service.getOrCreate('user-1'); + expect(result.userId).toBe('user-1'); + expect(inventoryRepo.save).toHaveBeenCalled(); + }); + }); + + // ─── addItem ───────────────────────────────────────────────────────────── + + describe('addItem', () => { + it('adds a new item when inventory has space', async () => { + inventoryRepo.findOne.mockResolvedValue({ ...baseInventory(), items: [] }); + itemRepo.findOne.mockResolvedValue(null); + itemRepo.create.mockReturnValue(baseItem()); + itemRepo.save.mockResolvedValue(baseItem()); + inventoryRepo.save.mockResolvedValue(baseInventory()); + + const result = await service.addItem('user-1', { + itemId: 'ref-item-1', + name: 'Fire Badge', + category: ItemCategory.ACHIEVEMENT, + rarity: ItemRarity.RARE, + }); + + expect(result.name).toBe('Fire Badge'); + }); + + it('stacks item when it already exists in inventory', async () => { + inventoryRepo.findOne.mockResolvedValue({ ...baseInventory(), items: [] }); + const existingItem = { ...baseItem(), quantity: 3, maxStack: 10 }; + itemRepo.findOne.mockResolvedValue(existingItem); + itemRepo.save.mockResolvedValue({ ...existingItem, quantity: 4 }); + + const result = await service.addItem('user-1', { + itemId: 'ref-item-1', + name: 'Fire Badge', + category: ItemCategory.ACHIEVEMENT, + rarity: ItemRarity.RARE, + quantity: 1, + }); + + expect(result.quantity).toBe(4); + }); + + it('throws when inventory is full', async () => { + inventoryRepo.findOne.mockResolvedValue({ + ...baseInventory(), + maxSlots: 1, + usedSlots: 1, + }); + itemRepo.findOne.mockResolvedValue(null); + + await expect( + service.addItem('user-1', { + itemId: 'ref-item-2', + name: 'Water Badge', + category: ItemCategory.ACHIEVEMENT, + rarity: ItemRarity.COMMON, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws when stack limit is exceeded', async () => { + inventoryRepo.findOne.mockResolvedValue({ ...baseInventory(), items: [] }); + const existingItem = { ...baseItem(), quantity: 10, maxStack: 10 }; + itemRepo.findOne.mockResolvedValue(existingItem); + + await expect( + service.addItem('user-1', { + itemId: 'ref-item-1', + name: 'Fire Badge', + category: ItemCategory.ACHIEVEMENT, + rarity: ItemRarity.RARE, + quantity: 1, + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ─── removeItem ────────────────────────────────────────────────────────── + + describe('removeItem', () => { + it('removes item completely when quantity >= stock', async () => { + inventoryRepo.findOne.mockResolvedValue({ ...baseInventory(), items: [] }); + itemRepo.findOne.mockResolvedValue({ ...baseItem(), quantity: 1 }); + inventoryRepo.save.mockResolvedValue(baseInventory()); + + await service.removeItem('user-1', 'item-1', 1); + expect(itemRepo.remove).toHaveBeenCalled(); + }); + + it('decrements quantity when removing less than full stack', async () => { + inventoryRepo.findOne.mockResolvedValue({ ...baseInventory(), items: [] }); + itemRepo.findOne.mockResolvedValue({ ...baseItem(), quantity: 5 }); + itemRepo.save.mockResolvedValue({ ...baseItem(), quantity: 4 }); + + await service.removeItem('user-1', 'item-1', 1); + expect(itemRepo.save).toHaveBeenCalled(); + }); + + it('throws when item not in inventory', async () => { + inventoryRepo.findOne.mockResolvedValue({ ...baseInventory(), items: [] }); + itemRepo.findOne.mockResolvedValue(null); + + await expect(service.removeItem('user-1', 'missing-id', 1)).rejects.toThrow(NotFoundException); + }); + }); + + // ─── getItems (sorting/filtering) ──────────────────────────────────────── + + describe('getItems', () => { + it('returns filtered items by category', async () => { + inventoryRepo.findOne.mockResolvedValue({ ...baseInventory(), items: [] }); + itemRepo.find.mockResolvedValue([baseItem()]); + + const results = await service.getItems('user-1', { category: ItemCategory.ACHIEVEMENT }); + expect(results).toHaveLength(1); + }); + + it('sorts by rarity in descending order', async () => { + inventoryRepo.findOne.mockResolvedValue({ ...baseInventory(), items: [] }); + const items = [ + { ...baseItem(), id: 'i1', rarity: ItemRarity.COMMON }, + { ...baseItem(), id: 'i2', rarity: ItemRarity.LEGENDARY }, + { ...baseItem(), id: 'i3', rarity: ItemRarity.RARE }, + ]; + itemRepo.find.mockResolvedValue(items); + + const results = await service.getItems('user-1', { sortBy: 'rarity', order: 'DESC' }); + expect(results[0].rarity).toBe(ItemRarity.LEGENDARY); + expect(results[2].rarity).toBe(ItemRarity.COMMON); + }); + }); + + // ─── getAnalytics ──────────────────────────────────────────────────────── + + describe('getAnalytics', () => { + it('computes category and rarity breakdown', async () => { + inventoryRepo.findOne.mockResolvedValue({ ...baseInventory(), items: [] }); + itemRepo.find.mockResolvedValue([ + { ...baseItem(), category: ItemCategory.ACHIEVEMENT, rarity: ItemRarity.RARE, quantity: 2 }, + { ...baseItem(), id: 'i2', category: ItemCategory.COSMETIC, rarity: ItemRarity.COMMON, quantity: 1 }, + ]); + + const analytics = await service.getAnalytics('user-1'); + expect(analytics.itemsByCategory[ItemCategory.ACHIEVEMENT]).toBe(2); + expect(analytics.itemsByCategory[ItemCategory.COSMETIC]).toBe(1); + expect(analytics.totalItems).toBe(3); + expect(analytics.mostPopularCategory).toBe(ItemCategory.ACHIEVEMENT); + }); + }); + + // ─── updateSlotLimit ───────────────────────────────────────────────────── + + describe('updateSlotLimit', () => { + it('updates slot limit successfully', async () => { + inventoryRepo.findOne.mockResolvedValue({ ...baseInventory(), usedSlots: 5 }); + inventoryRepo.save.mockResolvedValue({ ...baseInventory(), maxSlots: 200 }); + + const result = await service.updateSlotLimit('user-1', 200); + expect(result.maxSlots).toBe(200); + }); + + it('rejects if new limit is below used slots', async () => { + inventoryRepo.findOne.mockResolvedValue({ ...baseInventory(), usedSlots: 50 }); + await expect(service.updateSlotLimit('user-1', 10)).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/src/inventory/inventory.service.ts b/src/inventory/inventory.service.ts new file mode 100644 index 0000000..fb3cc04 --- /dev/null +++ b/src/inventory/inventory.service.ts @@ -0,0 +1,286 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindManyOptions } from 'typeorm'; +import { PlayerInventory, ItemCategory } from './entities/player-inventory.entity'; +import { InventoryItem, ItemRarity } from './entities/inventory-item.entity'; +import { AddItemDto, FilterInventoryDto } from './dto/inventory.dto'; + +export interface InventoryAnalytics { + userId: string; + totalItems: number; + usedSlots: number; + maxSlots: number; + itemsByCategory: Record; + itemsByRarity: Record; + mostPopularCategory: string | null; + popularItems: Array<{ name: string; quantity: number; rarity: ItemRarity }>; +} + +const RARITY_SORT_ORDER: Record = { + [ItemRarity.COMMON]: 1, + [ItemRarity.UNCOMMON]: 2, + [ItemRarity.RARE]: 3, + [ItemRarity.EPIC]: 4, + [ItemRarity.LEGENDARY]: 5, +}; + +@Injectable() +export class InventoryService { + private readonly logger = new Logger(InventoryService.name); + + constructor( + @InjectRepository(PlayerInventory) + private readonly inventoryRepository: Repository, + @InjectRepository(InventoryItem) + private readonly itemRepository: Repository, + ) {} + + // ─── Inventory Lifecycle ─────────────────────────────────────────────────── + + /** + * Get or create a player's inventory. + */ + async getOrCreate(userId: string): Promise { + let inventory = await this.inventoryRepository.findOne({ + where: { userId }, + relations: ['items'], + }); + + if (!inventory) { + inventory = this.inventoryRepository.create({ userId }); + inventory = await this.inventoryRepository.save(inventory); + this.logger.log(`Created new inventory for user ${userId}`); + } + + return inventory; + } + + async getInventory(userId: string): Promise { + const inventory = await this.inventoryRepository.findOne({ + where: { userId }, + relations: ['items'], + }); + + if (!inventory) { + throw new NotFoundException(`Inventory for user ${userId} not found`); + } + + return inventory; + } + + // ─── Item Storage ───────────────────────────────────────────────────────── + + /** + * Add an item to a player's inventory. + * Handles stacking for stackable items and enforces slot limits. + */ + async addItem(userId: string, dto: AddItemDto): Promise { + const inventory = await this.getOrCreate(userId); + + // Check if item already exists and is stackable + const existingItem = await this.itemRepository.findOne({ + where: { inventoryId: inventory.id, itemId: dto.itemId }, + }); + + if (existingItem) { + return this.stackItem(existingItem, dto.quantity ?? 1); + } + + // Enforce slot limit + if (inventory.usedSlots >= inventory.maxSlots) { + throw new BadRequestException( + `Inventory is full (${inventory.maxSlots} slots used)`, + ); + } + + const item = this.itemRepository.create({ + ...dto, + inventoryId: inventory.id, + quantity: dto.quantity ?? 1, + acquiredAt: new Date(), + }); + + const saved = await this.itemRepository.save(item); + + // Update inventory slot count and analytics + inventory.usedSlots += 1; + await this.updateAnalytics(inventory, dto.category); + await this.inventoryRepository.save(inventory); + + return saved; + } + + private async stackItem( + item: InventoryItem, + quantity: number, + ): Promise { + if ( + item.maxStack !== null && + item.maxStack !== undefined && + item.quantity + quantity > item.maxStack + ) { + throw new BadRequestException( + `Cannot add ${quantity} more of "${item.name}": stack limit is ${item.maxStack}`, + ); + } + + item.quantity += quantity; + return this.itemRepository.save(item); + } + + async removeItem(userId: string, itemId: string, quantity: number = 1): Promise { + const inventory = await this.getInventory(userId); + const item = await this.itemRepository.findOne({ + where: { id: itemId, inventoryId: inventory.id }, + }); + + if (!item) { + throw new NotFoundException(`Item ${itemId} not found in inventory`); + } + + if (quantity >= item.quantity) { + await this.itemRepository.remove(item); + inventory.usedSlots = Math.max(0, inventory.usedSlots - 1); + await this.inventoryRepository.save(inventory); + } else { + item.quantity -= quantity; + await this.itemRepository.save(item); + } + } + + // ─── Categorization / Filtering / Sorting ───────────────────────────────── + + async getItems( + userId: string, + filters: FilterInventoryDto = {}, + ): Promise { + const inventory = await this.getInventory(userId); + + const where: Record = { inventoryId: inventory.id }; + if (filters.category) where.category = filters.category; + if (filters.rarity) where.rarity = filters.rarity; + + const sortField = filters.sortBy ?? 'acquiredAt'; + const sortOrder = filters.order ?? 'DESC'; + + let items = await this.itemRepository.find({ + where, + order: sortField !== 'rarity' ? { [sortField]: sortOrder } : undefined, + }); + + // Rarity sorting requires client-side ordering (enum not sortable natively) + if (sortField === 'rarity') { + items.sort((a, b) => { + const diff = + RARITY_SORT_ORDER[a.rarity] - RARITY_SORT_ORDER[b.rarity]; + return sortOrder === 'ASC' ? diff : -diff; + }); + } + + return items; + } + + async getItemsByCategory( + userId: string, + category: ItemCategory, + ): Promise { + const inventory = await this.getInventory(userId); + return this.itemRepository.find({ + where: { inventoryId: inventory.id, category }, + order: { acquiredAt: 'DESC' }, + }); + } + + // ─── Persistence ────────────────────────────────────────────────────────── + + async getItem(userId: string, itemId: string): Promise { + const inventory = await this.getInventory(userId); + const item = await this.itemRepository.findOne({ + where: { id: itemId, inventoryId: inventory.id }, + }); + + if (!item) { + throw new NotFoundException(`Item ${itemId} not found`); + } + + return item; + } + + // ─── Analytics ──────────────────────────────────────────────────────────── + + async getAnalytics(userId: string): Promise { + const inventory = await this.getInventory(userId); + const items = await this.itemRepository.find({ + where: { inventoryId: inventory.id }, + }); + + const itemsByCategory: Record = {}; + const itemsByRarity: Record = {}; + + for (const item of items) { + itemsByCategory[item.category] = + (itemsByCategory[item.category] ?? 0) + item.quantity; + itemsByRarity[item.rarity] = + (itemsByRarity[item.rarity] ?? 0) + item.quantity; + } + + const mostPopularCategory = + Object.entries(itemsByCategory).sort(([, a], [, b]) => b - a)[0]?.[0] ?? + null; + + const popularItems = items + .sort((a, b) => b.quantity - a.quantity) + .slice(0, 5) + .map((i) => ({ name: i.name, quantity: i.quantity, rarity: i.rarity })); + + return { + userId, + totalItems: items.reduce((sum, i) => sum + i.quantity, 0), + usedSlots: inventory.usedSlots, + maxSlots: inventory.maxSlots, + itemsByCategory, + itemsByRarity, + mostPopularCategory, + popularItems, + }; + } + + private async updateAnalytics( + inventory: PlayerInventory, + category: ItemCategory, + ): Promise { + const breakdown = inventory.analytics.categoryBreakdown ?? {}; + breakdown[category] = (breakdown[category] ?? 0) + 1; + + const totalEver = (inventory.analytics.totalItemsEverAcquired ?? 0) + 1; + const mostPopular = + Object.entries(breakdown).sort(([, a], [, b]) => b - a)[0]?.[0] ?? + undefined; + + inventory.analytics = { + totalItemsEverAcquired: totalEver, + categoryBreakdown: breakdown, + mostPopularCategory: mostPopular, + }; + } + + // ─── Inventory Limits ───────────────────────────────────────────────────── + + async updateSlotLimit(userId: string, maxSlots: number): Promise { + const inventory = await this.getOrCreate(userId); + + if (maxSlots < inventory.usedSlots) { + throw new BadRequestException( + `Cannot reduce slot limit to ${maxSlots}: ${inventory.usedSlots} slots are currently in use`, + ); + } + + inventory.maxSlots = maxSlots; + return this.inventoryRepository.save(inventory); + } +} From 484f928cf61314932d9925a274b3305800716023 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Sun, 27 Sep 2026 18:26:11 +0100 Subject: [PATCH 3/4] feat(battle-pass): implement seasonal battle pass and progression (#453) --- src/battle-pass/battle-pass.controller.ts | 146 +++++++ src/battle-pass/battle-pass.module.ts | 17 + src/battle-pass/battle-pass.service.spec.ts | 266 ++++++++++++ src/battle-pass/battle-pass.service.ts | 400 ++++++++++++++++++ src/battle-pass/dto/battle-pass.dto.ts | 119 ++++++ src/battle-pass/dto/index.ts | 1 + .../entities/battle-pass-tier.entity.ts | 63 +++ .../entities/battle-pass.entity.ts | 74 ++++ src/battle-pass/entities/index.ts | 3 + .../entities/player-battle-pass.entity.ts | 59 +++ 10 files changed, 1148 insertions(+) create mode 100644 src/battle-pass/battle-pass.controller.ts create mode 100644 src/battle-pass/battle-pass.module.ts create mode 100644 src/battle-pass/battle-pass.service.spec.ts create mode 100644 src/battle-pass/battle-pass.service.ts create mode 100644 src/battle-pass/dto/battle-pass.dto.ts create mode 100644 src/battle-pass/dto/index.ts create mode 100644 src/battle-pass/entities/battle-pass-tier.entity.ts create mode 100644 src/battle-pass/entities/battle-pass.entity.ts create mode 100644 src/battle-pass/entities/index.ts create mode 100644 src/battle-pass/entities/player-battle-pass.entity.ts diff --git a/src/battle-pass/battle-pass.controller.ts b/src/battle-pass/battle-pass.controller.ts new file mode 100644 index 0000000..63614b2 --- /dev/null +++ b/src/battle-pass/battle-pass.controller.ts @@ -0,0 +1,146 @@ +import { + Controller, + Get, + Post, + Body, + Param, + ParseUUIDPipe, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { BattlePassService } from './battle-pass.service'; +import { + CreateBattlePassDto, + CreateTierDto, + EarnXpDto, + ClaimRewardDto, +} from './dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../auth/constants'; +import { ActiveUser } from '../auth/decorators/active-user.decorator'; + +@ApiTags('Battle Pass') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('battle-pass') +export class BattlePassController { + constructor(private readonly service: BattlePassService) {} + + // ─── Season Management ───────────────────────────────────────────────────── + + @Post() + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Create a new battle pass season (admin only)' }) + async create(@Body() dto: CreateBattlePassDto) { + return this.service.create(dto); + } + + @Get('active') + @ApiOperation({ summary: 'Get all active battle pass seasons' }) + async getActive() { + return this.service.findActive(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a battle pass by ID' }) + async findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findOne(id); + } + + @Get('season/:season') + @ApiOperation({ summary: 'Get a battle pass by season identifier' }) + async findBySeason(@Param('season') season: string) { + return this.service.findBySeason(season); + } + + // ─── Tiers ───────────────────────────────────────────────────────────────── + + @Post(':id/tiers') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Add a tier to a battle pass (admin only)' }) + async addTier( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CreateTierDto, + ) { + return this.service.addTier(id, dto); + } + + @Get(':id/tiers') + @ApiOperation({ summary: 'Get all tiers for a battle pass' }) + async getTiers(@Param('id', ParseUUIDPipe) id: string) { + return this.service.getTiers(id); + } + + // ─── Player Progression ──────────────────────────────────────────────────── + + @Get(':id/progress') + @ApiOperation({ summary: "Get the authenticated user's battle pass progress" }) + async getProgress( + @Param('id', ParseUUIDPipe) id: string, + @ActiveUser() user: any, + ) { + return this.service.getOrCreatePlayerProgress(user.id ?? user.sub, id); + } + + @Post(':id/xp') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Award XP and advance tiers' }) + @ApiResponse({ status: 200, description: 'XP awarded and tier progression result returned' }) + async earnXp( + @Param('id', ParseUUIDPipe) id: string, + @ActiveUser() user: any, + @Body() dto: EarnXpDto, + ) { + return this.service.earnXp(user.id ?? user.sub, id, dto); + } + + // ─── Premium Track ───────────────────────────────────────────────────────── + + @Post(':id/purchase-premium') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Purchase the premium track with retroactive rewards' }) + @ApiResponse({ status: 200, description: 'Premium purchased; retroactive rewards granted' }) + async purchasePremium( + @Param('id', ParseUUIDPipe) id: string, + @ActiveUser() user: any, + ) { + return this.service.purchasePremium(user.id ?? user.sub, id); + } + + // ─── Reward Claiming ─────────────────────────────────────────────────────── + + @Post(':id/claim') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Claim a tier reward on the free or premium track' }) + @ApiResponse({ status: 200, description: 'Reward claimed' }) + async claimReward( + @Param('id', ParseUUIDPipe) id: string, + @ActiveUser() user: any, + @Body() dto: ClaimRewardDto, + ) { + return this.service.claimReward(user.id ?? user.sub, id, dto); + } + + // ─── Analytics ───────────────────────────────────────────────────────────── + + @Get(':id/analytics') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get battle pass engagement analytics (admin only)' }) + async getAnalytics(@Param('id', ParseUUIDPipe) id: string) { + return this.service.getAnalytics(id); + } +} diff --git a/src/battle-pass/battle-pass.module.ts b/src/battle-pass/battle-pass.module.ts new file mode 100644 index 0000000..25b7050 --- /dev/null +++ b/src/battle-pass/battle-pass.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; +import { BattlePass, BattlePassTier, PlayerBattlePass } from './entities'; +import { BattlePassService } from './battle-pass.service'; +import { BattlePassController } from './battle-pass.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([BattlePass, BattlePassTier, PlayerBattlePass]), + ScheduleModule.forRoot(), + ], + controllers: [BattlePassController], + providers: [BattlePassService], + exports: [BattlePassService], +}) +export class BattlePassModule {} diff --git a/src/battle-pass/battle-pass.service.spec.ts b/src/battle-pass/battle-pass.service.spec.ts new file mode 100644 index 0000000..93d20d8 --- /dev/null +++ b/src/battle-pass/battle-pass.service.spec.ts @@ -0,0 +1,266 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BattlePassService } from './battle-pass.service'; +import { BattlePass, BattlePassStatus } from './entities/battle-pass.entity'; +import { BattlePassTier } from './entities/battle-pass-tier.entity'; +import { PlayerBattlePass } from './entities/player-battle-pass.entity'; + +const mockRepo = () => ({ + create: jest.fn((d) => d), + save: jest.fn((d) => Promise.resolve({ ...d, id: d.id ?? 'generated-id' })), + findOne: jest.fn(), + find: jest.fn(), + increment: jest.fn().mockResolvedValue(undefined), +}); + +const baseBattlePass = (): BattlePass => ({ + id: 'bp-1', + name: 'Season One', + season: '2025-Q1', + startDate: new Date(Date.now() - 1000), + endDate: new Date(Date.now() + 86400000 * 90), + status: BattlePassStatus.ACTIVE, + xpPerTier: 1000, + totalTiers: 50, + analytics: {}, + createdAt: new Date(), + updatedAt: new Date(), + tiers: [], + playerProgress: [], +}); + +const baseTier = (n: number, xp: number): BattlePassTier => ({ + id: `tier-${n}`, + battlePassId: 'bp-1', + tierNumber: n, + xpRequired: xp, + freeReward: { type: 'points', name: `Tier ${n} Free Reward`, value: 100 }, + premiumReward: { type: 'badge', name: `Tier ${n} Premium Badge` }, + createdAt: new Date(), + updatedAt: new Date(), + battlePass: {} as any, +}); + +const baseProgress = (): PlayerBattlePass => ({ + id: 'prog-1', + userId: 'user-1', + battlePassId: 'bp-1', + isPremium: false, + currentXp: 0, + currentTier: 0, + claimedFreeRewards: [], + claimedPremiumRewards: [], + createdAt: new Date(), + updatedAt: new Date(), + battlePass: {} as any, +}); + +describe('BattlePassService', () => { + let service: BattlePassService; + let bpRepo: ReturnType; + let tierRepo: ReturnType; + let progressRepo: ReturnType; + + beforeEach(async () => { + bpRepo = mockRepo(); + tierRepo = mockRepo(); + progressRepo = mockRepo(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BattlePassService, + { provide: getRepositoryToken(BattlePass), useValue: bpRepo }, + { provide: getRepositoryToken(BattlePassTier), useValue: tierRepo }, + { provide: getRepositoryToken(PlayerBattlePass), useValue: progressRepo }, + ], + }).compile(); + + service = module.get(BattlePassService); + }); + + afterEach(() => jest.clearAllMocks()); + + // ─── create ────────────────────────────────────────────────────────────── + + describe('create', () => { + it('creates a battle pass in DRAFT status', async () => { + bpRepo.findOne.mockResolvedValue(null); + bpRepo.create.mockReturnValue({ ...baseBattlePass(), status: BattlePassStatus.DRAFT }); + bpRepo.save.mockResolvedValue({ ...baseBattlePass(), status: BattlePassStatus.DRAFT }); + + const result = await service.create({ + name: 'Season One', + season: '2025-Q1', + startDate: new Date(Date.now() + 1000), + endDate: new Date(Date.now() + 86400000), + xpPerTier: 1000, + totalTiers: 50, + }); + + expect(result.status).toBe(BattlePassStatus.DRAFT); + }); + + it('rejects duplicate season', async () => { + bpRepo.findOne.mockResolvedValue(baseBattlePass()); + + await expect( + service.create({ + name: 'Season One', + season: '2025-Q1', + startDate: new Date(), + endDate: new Date(Date.now() + 86400000), + xpPerTier: 1000, + totalTiers: 50, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects when startDate >= endDate', async () => { + bpRepo.findOne.mockResolvedValue(null); + const now = new Date(); + await expect( + service.create({ + name: 'Bad Pass', + season: '2025-Q2', + startDate: now, + endDate: now, + xpPerTier: 1000, + totalTiers: 50, + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ─── earnXp ────────────────────────────────────────────────────────────── + + describe('earnXp', () => { + it('advances tiers when XP threshold is crossed', async () => { + bpRepo.findOne.mockResolvedValue(baseBattlePass()); + progressRepo.findOne.mockResolvedValue({ ...baseProgress(), currentXp: 0, currentTier: 0 }); + tierRepo.find.mockResolvedValue([ + baseTier(1, 1000), + baseTier(2, 2000), + ]); + progressRepo.save.mockImplementation((d) => Promise.resolve(d)); + + const result = await service.earnXp('user-1', 'bp-1', { xp: 1500 }); + + expect(result.currentTier).toBe(1); + expect(result.newlyUnlockedTiers).toHaveLength(1); + expect(result.currentXp).toBe(1500); + }); + + it('can unlock multiple tiers in a single XP grant', async () => { + bpRepo.findOne.mockResolvedValue(baseBattlePass()); + progressRepo.findOne.mockResolvedValue({ ...baseProgress() }); + tierRepo.find.mockResolvedValue([baseTier(1, 1000), baseTier(2, 2000), baseTier(3, 3000)]); + progressRepo.save.mockImplementation((d) => Promise.resolve(d)); + + const result = await service.earnXp('user-1', 'bp-1', { xp: 3500 }); + expect(result.currentTier).toBe(3); + expect(result.newlyUnlockedTiers).toHaveLength(3); + }); + + it('throws when battle pass is not active', async () => { + bpRepo.findOne.mockResolvedValue({ ...baseBattlePass(), status: BattlePassStatus.EXPIRED }); + + await expect( + service.earnXp('user-1', 'bp-1', { xp: 500 }), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ─── purchasePremium ───────────────────────────────────────────────────── + + describe('purchasePremium', () => { + it('grants premium and retroactive rewards', async () => { + bpRepo.findOne.mockResolvedValue(baseBattlePass()); + const progress = { ...baseProgress(), currentTier: 2, claimedPremiumRewards: [] }; + progressRepo.findOne.mockResolvedValue(progress); + tierRepo.find.mockResolvedValue([baseTier(1, 1000), baseTier(2, 2000)]); + progressRepo.save.mockImplementation((d) => Promise.resolve(d)); + bpRepo.save.mockResolvedValue(baseBattlePass()); + + const result = await service.purchasePremium('user-1', 'bp-1'); + + expect(result.isPremium).toBe(true); + expect(result.claimedPremiumRewards).toHaveLength(2); // retroactive for tier 1 & 2 + expect(result.retroactivePurchaseGrantedAt).toBeDefined(); + }); + + it('throws when player already has premium', async () => { + bpRepo.findOne.mockResolvedValue(baseBattlePass()); + progressRepo.findOne.mockResolvedValue({ ...baseProgress(), isPremium: true }); + + await expect( + service.purchasePremium('user-1', 'bp-1'), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ─── claimReward ───────────────────────────────────────────────────────── + + describe('claimReward', () => { + it('claims a free tier reward successfully', async () => { + progressRepo.findOne.mockResolvedValue({ ...baseProgress(), currentTier: 1, claimedFreeRewards: [] }); + bpRepo.findOne.mockResolvedValue(baseBattlePass()); + tierRepo.findOne.mockResolvedValue(baseTier(1, 1000)); + progressRepo.save.mockImplementation((d) => Promise.resolve(d)); + + const result = await service.claimReward('user-1', 'bp-1', { tierNumber: 1, track: 'free' }); + expect(result.reward).toBeDefined(); + }); + + it('throws when tier not yet unlocked', async () => { + progressRepo.findOne.mockResolvedValue({ ...baseProgress(), currentTier: 0 }); + bpRepo.findOne.mockResolvedValue(baseBattlePass()); + + await expect( + service.claimReward('user-1', 'bp-1', { tierNumber: 1, track: 'free' }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws when premium reward claimed without premium track', async () => { + progressRepo.findOne.mockResolvedValue({ ...baseProgress(), currentTier: 1, isPremium: false }); + bpRepo.findOne.mockResolvedValue(baseBattlePass()); + + await expect( + service.claimReward('user-1', 'bp-1', { tierNumber: 1, track: 'premium' }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws when reward already claimed', async () => { + progressRepo.findOne.mockResolvedValue({ + ...baseProgress(), + currentTier: 1, + claimedFreeRewards: ['tier-1'], + }); + bpRepo.findOne.mockResolvedValue(baseBattlePass()); + tierRepo.findOne.mockResolvedValue(baseTier(1, 1000)); + + await expect( + service.claimReward('user-1', 'bp-1', { tierNumber: 1, track: 'free' }), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ─── getAnalytics ──────────────────────────────────────────────────────── + + describe('getAnalytics', () => { + it('computes analytics correctly', async () => { + bpRepo.findOne.mockResolvedValue(baseBattlePass()); + progressRepo.find.mockResolvedValue([ + { ...baseProgress(), currentXp: 2000, currentTier: 2, isPremium: true }, + { ...baseProgress(), id: 'p2', userId: 'u2', currentXp: 1000, currentTier: 1, isPremium: false }, + ]); + tierRepo.find.mockResolvedValue([baseTier(1, 1000), baseTier(2, 2000)]); + + const analytics = await service.getAnalytics('bp-1'); + expect(analytics.totalPlayers).toBe(2); + expect(analytics.premiumPlayers).toBe(1); + expect(analytics.premiumConversionRate).toBe(0.5); + expect(analytics.averageXpEarned).toBe(1500); + }); + }); +}); diff --git a/src/battle-pass/battle-pass.service.ts b/src/battle-pass/battle-pass.service.ts new file mode 100644 index 0000000..334c45d --- /dev/null +++ b/src/battle-pass/battle-pass.service.ts @@ -0,0 +1,400 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThanOrEqual, MoreThan } from 'typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { + BattlePass, + BattlePassStatus, +} from './entities/battle-pass.entity'; +import { BattlePassTier } from './entities/battle-pass-tier.entity'; +import { PlayerBattlePass } from './entities/player-battle-pass.entity'; +import { + CreateBattlePassDto, + CreateTierDto, + EarnXpDto, + ClaimRewardDto, +} from './dto/battle-pass.dto'; + +export interface TierProgressResult { + previousTier: number; + currentTier: number; + currentXp: number; + newlyUnlockedTiers: BattlePassTier[]; +} + +export interface BattlePassAnalytics { + battlePassId: string; + season: string; + totalPlayers: number; + premiumPlayers: number; + premiumConversionRate: number; + averageXpEarned: number; + averageTierReached: number; + tierCompletionRates: Array<{ tier: number; completionRate: number }>; +} + +@Injectable() +export class BattlePassService { + private readonly logger = new Logger(BattlePassService.name); + + constructor( + @InjectRepository(BattlePass) + private readonly battlePassRepository: Repository, + @InjectRepository(BattlePassTier) + private readonly tierRepository: Repository, + @InjectRepository(PlayerBattlePass) + private readonly playerBattlePassRepository: Repository, + ) {} + + // ─── Season Management ───────────────────────────────────────────────────── + + /** + * Cron: activate/expire battle passes automatically. + */ + @Cron(CronExpression.EVERY_5_MINUTES) + async handleSeasonManagement(): Promise { + this.logger.log('Running battle-pass season management cron'); + const now = new Date(); + + // Activate + const toActivate = await this.battlePassRepository.find({ + where: { + status: BattlePassStatus.DRAFT, + startDate: LessThanOrEqual(now), + endDate: MoreThan(now), + }, + }); + for (const bp of toActivate) { + bp.status = BattlePassStatus.ACTIVE; + await this.battlePassRepository.save(bp); + this.logger.log(`Activated battle pass season: ${bp.season}`); + } + + // Expire + const toExpire = await this.battlePassRepository.find({ + where: { status: BattlePassStatus.ACTIVE }, + }); + for (const bp of toExpire) { + if (bp.endDate <= now) { + bp.status = BattlePassStatus.EXPIRED; + await this.battlePassRepository.save(bp); + this.logger.log(`Expired battle pass season: ${bp.season}`); + } + } + } + + // ─── CRUD ────────────────────────────────────────────────────────────────── + + async create(dto: CreateBattlePassDto): Promise { + if (dto.startDate >= dto.endDate) { + throw new BadRequestException('startDate must be before endDate'); + } + + const existing = await this.battlePassRepository.findOne({ + where: { season: dto.season }, + }); + if (existing) { + throw new BadRequestException( + `A battle pass for season "${dto.season}" already exists`, + ); + } + + const battlePass = this.battlePassRepository.create({ + ...dto, + status: BattlePassStatus.DRAFT, + }); + + return this.battlePassRepository.save(battlePass); + } + + async findOne(id: string): Promise { + const bp = await this.battlePassRepository.findOne({ + where: { id }, + relations: ['tiers'], + }); + if (!bp) throw new NotFoundException(`Battle pass ${id} not found`); + return bp; + } + + async findActive(): Promise { + return this.battlePassRepository.find({ + where: { status: BattlePassStatus.ACTIVE }, + }); + } + + async findBySeason(season: string): Promise { + const bp = await this.battlePassRepository.findOne({ + where: { season }, + relations: ['tiers'], + }); + if (!bp) + throw new NotFoundException(`Battle pass for season "${season}" not found`); + return bp; + } + + // ─── Tier Management ─────────────────────────────────────────────────────── + + async addTier(battlePassId: string, dto: CreateTierDto): Promise { + await this.findOne(battlePassId); + + const existing = await this.tierRepository.findOne({ + where: { battlePassId, tierNumber: dto.tierNumber }, + }); + if (existing) { + throw new BadRequestException( + `Tier ${dto.tierNumber} already exists for this battle pass`, + ); + } + + const tier = this.tierRepository.create({ ...dto, battlePassId }); + return this.tierRepository.save(tier); + } + + async getTiers(battlePassId: string): Promise { + await this.findOne(battlePassId); + return this.tierRepository.find({ + where: { battlePassId }, + order: { tierNumber: 'ASC' }, + }); + } + + // ─── Player Progression ──────────────────────────────────────────────────── + + /** + * Get or initialise a player's progress record for a battle pass. + */ + async getOrCreatePlayerProgress( + userId: string, + battlePassId: string, + ): Promise { + let progress = await this.playerBattlePassRepository.findOne({ + where: { userId, battlePassId }, + }); + + if (!progress) { + await this.findOne(battlePassId); // validate battle pass exists + progress = this.playerBattlePassRepository.create({ + userId, + battlePassId, + isPremium: false, + currentXp: 0, + currentTier: 0, + claimedFreeRewards: [], + claimedPremiumRewards: [], + }); + progress = await this.playerBattlePassRepository.save(progress); + } + + return progress; + } + + /** + * Award XP to a player and advance tiers accordingly. + */ + async earnXp( + userId: string, + battlePassId: string, + dto: EarnXpDto, + ): Promise { + const bp = await this.findOne(battlePassId); + + if (bp.status !== BattlePassStatus.ACTIVE) { + throw new BadRequestException('Battle pass season is not active'); + } + + const progress = await this.getOrCreatePlayerProgress(userId, battlePassId); + const previousTier = progress.currentTier; + + progress.currentXp += dto.xp; + + const tiers = await this.getTiers(battlePassId); + + // Advance tiers based on cumulative XP + const newlyUnlockedTiers: BattlePassTier[] = []; + for (const tier of tiers) { + if ( + progress.currentXp >= tier.xpRequired && + tier.tierNumber > progress.currentTier + ) { + progress.currentTier = tier.tierNumber; + newlyUnlockedTiers.push(tier); + } + } + + await this.playerBattlePassRepository.save(progress); + + return { + previousTier, + currentTier: progress.currentTier, + currentXp: progress.currentXp, + newlyUnlockedTiers, + }; + } + + // ─── Dual Tracks (Free / Premium) ───────────────────────────────────────── + + /** + * Purchase the premium track. Grants retroactive premium rewards for all + * tiers already unlocked by the player. + */ + async purchasePremium( + userId: string, + battlePassId: string, + ): Promise { + const bp = await this.findOne(battlePassId); + + if (bp.status !== BattlePassStatus.ACTIVE) { + throw new BadRequestException('Battle pass season is not active'); + } + + const progress = await this.getOrCreatePlayerProgress(userId, battlePassId); + + if (progress.isPremium) { + throw new BadRequestException('Player already has premium for this season'); + } + + progress.isPremium = true; + progress.premiumPurchasedAt = new Date(); + + // Retroactive: grant all premium rewards for tiers already reached + const tiers = await this.tierRepository.find({ + where: { battlePassId }, + order: { tierNumber: 'ASC' }, + }); + + const retroactiveTiers = tiers.filter( + (t) => + t.tierNumber <= progress.currentTier && + t.premiumReward && + !progress.claimedPremiumRewards.includes(t.id), + ); + + for (const tier of retroactiveTiers) { + progress.claimedPremiumRewards.push(tier.id); + } + + progress.retroactivePurchaseGrantedAt = + retroactiveTiers.length > 0 ? new Date() : undefined; + + const saved = await this.playerBattlePassRepository.save(progress); + + // Update analytics + await this.battlePassRepository.increment( + { id: battlePassId }, + 'analytics', + 0, // Workaround – update via raw save + ); + bp.analytics = { + ...bp.analytics, + premiumPurchases: (bp.analytics.premiumPurchases ?? 0) + 1, + totalPurchases: (bp.analytics.totalPurchases ?? 0) + 1, + }; + await this.battlePassRepository.save(bp); + + this.logger.log( + `User ${userId} purchased premium for season ${bp.season}; ` + + `granted ${retroactiveTiers.length} retroactive rewards`, + ); + + return saved; + } + + // ─── Reward Claiming ─────────────────────────────────────────────────────── + + async claimReward( + userId: string, + battlePassId: string, + dto: ClaimRewardDto, + ): Promise<{ reward: object }> { + const progress = await this.getOrCreatePlayerProgress(userId, battlePassId); + + if (dto.tierNumber > progress.currentTier) { + throw new BadRequestException( + `Tier ${dto.tierNumber} has not been unlocked yet`, + ); + } + + if (dto.track === 'premium' && !progress.isPremium) { + throw new BadRequestException( + 'Premium track requires purchasing the premium battle pass', + ); + } + + const tier = await this.tierRepository.findOne({ + where: { battlePassId, tierNumber: dto.tierNumber }, + }); + + if (!tier) { + throw new NotFoundException(`Tier ${dto.tierNumber} not found`); + } + + const claimedList = + dto.track === 'free' + ? progress.claimedFreeRewards + : progress.claimedPremiumRewards; + + if (claimedList.includes(tier.id)) { + throw new BadRequestException( + `Reward for tier ${dto.tierNumber} (${dto.track}) already claimed`, + ); + } + + const reward = + dto.track === 'free' ? tier.freeReward : tier.premiumReward; + + if (!reward) { + throw new BadRequestException( + `No ${dto.track} reward defined for tier ${dto.tierNumber}`, + ); + } + + claimedList.push(tier.id); + if (dto.track === 'free') { + progress.claimedFreeRewards = claimedList; + } else { + progress.claimedPremiumRewards = claimedList; + } + + await this.playerBattlePassRepository.save(progress); + return { reward }; + } + + // ─── Analytics ───────────────────────────────────────────────────────────── + + async getAnalytics(battlePassId: string): Promise { + const bp = await this.findOne(battlePassId); + const allProgress = await this.playerBattlePassRepository.find({ + where: { battlePassId }, + }); + + const tiers = await this.getTiers(battlePassId); + const totalPlayers = allProgress.length; + const premiumPlayers = allProgress.filter((p) => p.isPremium).length; + const totalXp = allProgress.reduce((s, p) => s + p.currentXp, 0); + const totalTier = allProgress.reduce((s, p) => s + p.currentTier, 0); + + const tierCompletionRates = tiers.map((t) => ({ + tier: t.tierNumber, + completionRate: totalPlayers + ? allProgress.filter((p) => p.currentTier >= t.tierNumber).length / + totalPlayers + : 0, + })); + + return { + battlePassId, + season: bp.season, + totalPlayers, + premiumPlayers, + premiumConversionRate: totalPlayers ? premiumPlayers / totalPlayers : 0, + averageXpEarned: totalPlayers ? totalXp / totalPlayers : 0, + averageTierReached: totalPlayers ? totalTier / totalPlayers : 0, + tierCompletionRates, + }; + } +} diff --git a/src/battle-pass/dto/battle-pass.dto.ts b/src/battle-pass/dto/battle-pass.dto.ts new file mode 100644 index 0000000..c04899f --- /dev/null +++ b/src/battle-pass/dto/battle-pass.dto.ts @@ -0,0 +1,119 @@ +import { + IsString, + IsNotEmpty, + IsDate, + IsOptional, + IsInt, + Min, + IsArray, + ValidateNested, + IsObject, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +class TierRewardDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + type: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional() + @IsInt() + @Min(0) + @IsOptional() + value?: number; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + imageUrl?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + rarity?: string; +} + +export class CreateBattlePassDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + description?: string; + + @ApiProperty({ description: 'Season identifier, e.g. "2025-Q1"' }) + @IsString() + @IsNotEmpty() + season: string; + + @ApiProperty() + @IsDate() + @Type(() => Date) + startDate: Date; + + @ApiProperty() + @IsDate() + @Type(() => Date) + endDate: Date; + + @ApiProperty({ description: 'XP required to advance one tier' }) + @IsInt() + @Min(1) + xpPerTier: number; + + @ApiProperty({ description: 'Total number of tiers in this season' }) + @IsInt() + @Min(1) + totalTiers: number; +} + +export class CreateTierDto { + @ApiProperty({ description: 'Tier number (1-based)' }) + @IsInt() + @Min(1) + tierNumber: number; + + @ApiProperty({ description: 'Cumulative XP required to unlock this tier' }) + @IsInt() + @Min(0) + xpRequired: number; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + freeReward?: TierRewardDto; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + premiumReward?: TierRewardDto; +} + +export class EarnXpDto { + @ApiProperty({ description: 'XP points to award' }) + @IsInt() + @Min(1) + xp: number; +} + +export class ClaimRewardDto { + @ApiProperty({ description: 'Tier number to claim reward from' }) + @IsInt() + @Min(1) + tierNumber: number; + + @ApiProperty({ enum: ['free', 'premium'] }) + @IsString() + @IsNotEmpty() + track: 'free' | 'premium'; +} diff --git a/src/battle-pass/dto/index.ts b/src/battle-pass/dto/index.ts new file mode 100644 index 0000000..1b66278 --- /dev/null +++ b/src/battle-pass/dto/index.ts @@ -0,0 +1 @@ +export * from './battle-pass.dto'; diff --git a/src/battle-pass/entities/battle-pass-tier.entity.ts b/src/battle-pass/entities/battle-pass-tier.entity.ts new file mode 100644 index 0000000..c13ef51 --- /dev/null +++ b/src/battle-pass/entities/battle-pass-tier.entity.ts @@ -0,0 +1,63 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; +import { BattlePass } from './battle-pass.entity'; + +export enum RewardTrack { + FREE = 'free', + PREMIUM = 'premium', +} + +@Entity('battle_pass_tiers') +@Index(['battlePassId', 'tierNumber']) +export class BattlePassTier { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + battlePassId: string; + + @Column({ type: 'int' }) + tierNumber: number; // 1-based + + @Column({ type: 'int' }) + xpRequired: number; // Cumulative XP to reach this tier + + @Column({ type: 'jsonb', nullable: true }) + freeReward?: { + type: 'points' | 'badge' | 'item' | 'currency' | 'title' | 'avatar'; + name: string; + value?: number; + imageUrl?: string; + rarity?: 'common' | 'rare' | 'epic' | 'legendary'; + metadata?: Record; + }; + + @Column({ type: 'jsonb', nullable: true }) + premiumReward?: { + type: 'points' | 'badge' | 'item' | 'currency' | 'title' | 'avatar'; + name: string; + value?: number; + imageUrl?: string; + rarity?: 'common' | 'rare' | 'epic' | 'legendary'; + metadata?: Record; + }; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @ManyToOne(() => BattlePass, (bp) => bp.tiers, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'battlePassId' }) + battlePass: BattlePass; +} diff --git a/src/battle-pass/entities/battle-pass.entity.ts b/src/battle-pass/entities/battle-pass.entity.ts new file mode 100644 index 0000000..91ab2af --- /dev/null +++ b/src/battle-pass/entities/battle-pass.entity.ts @@ -0,0 +1,74 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { BattlePassTier } from './battle-pass-tier.entity'; +import { PlayerBattlePass } from './player-battle-pass.entity'; + +export enum BattlePassStatus { + DRAFT = 'draft', + ACTIVE = 'active', + EXPIRED = 'expired', +} + +@Entity('battle_passes') +@Index(['status']) +export class BattlePass { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'varchar', length: 100 }) + @Index() + season: string; // e.g. "2025-Q1" + + @Column({ type: 'timestamp with time zone' }) + @Index() + startDate: Date; + + @Column({ type: 'timestamp with time zone' }) + @Index() + endDate: Date; + + @Column({ + type: 'enum', + enum: BattlePassStatus, + default: BattlePassStatus.DRAFT, + }) + status: BattlePassStatus; + + @Column({ type: 'int', default: 0 }) + xpPerTier: number; // XP required to advance one tier + + @Column({ type: 'int', default: 100 }) + totalTiers: number; + + @Column({ type: 'jsonb', default: {} }) + analytics: { + totalPurchases?: number; + premiumPurchases?: number; + averageXpEarned?: number; + }; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @OneToMany(() => BattlePassTier, (tier) => tier.battlePass, { cascade: true }) + tiers: BattlePassTier[]; + + @OneToMany(() => PlayerBattlePass, (p) => p.battlePass, { cascade: true }) + playerProgress: PlayerBattlePass[]; +} diff --git a/src/battle-pass/entities/index.ts b/src/battle-pass/entities/index.ts new file mode 100644 index 0000000..95012ab --- /dev/null +++ b/src/battle-pass/entities/index.ts @@ -0,0 +1,3 @@ +export * from './battle-pass.entity'; +export * from './battle-pass-tier.entity'; +export * from './player-battle-pass.entity'; diff --git a/src/battle-pass/entities/player-battle-pass.entity.ts b/src/battle-pass/entities/player-battle-pass.entity.ts new file mode 100644 index 0000000..6625f48 --- /dev/null +++ b/src/battle-pass/entities/player-battle-pass.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; +import { BattlePass } from './battle-pass.entity'; + +@Entity('player_battle_passes') +@Unique(['userId', 'battlePassId']) +@Index(['userId', 'battlePassId']) +export class PlayerBattlePass { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + userId: string; + + @Column({ type: 'uuid' }) + @Index() + battlePassId: string; + + @Column({ type: 'boolean', default: false }) + isPremium: boolean; // Has the user purchased the premium track? + + @Column({ type: 'int', default: 0 }) + currentXp: number; + + @Column({ type: 'int', default: 0 }) + currentTier: number; // 0-based (0 = no tier unlocked) + + @Column({ type: 'jsonb', default: [] }) + claimedFreeRewards: string[]; // tier IDs + + @Column({ type: 'jsonb', default: [] }) + claimedPremiumRewards: string[]; // tier IDs + + @Column({ type: 'timestamp with time zone', nullable: true }) + premiumPurchasedAt?: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + retroactivePurchaseGrantedAt?: Date; // When retroactive rewards were granted + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @ManyToOne(() => BattlePass, (bp) => bp.playerProgress, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'battlePassId' }) + battlePass: BattlePass; +} From 9896ed044028c1f2d0ef2ab0cb4aa970349a244a Mon Sep 17 00:00:00 2001 From: Ahmed Date: Sun, 27 Sep 2026 18:26:54 +0100 Subject: [PATCH 4/4] feat(account): implement progression reset and account management (#454) --- .../account-management.service.spec.ts | 246 +++++++++++++++ src/account/account-management.service.ts | 280 ++++++++++++++++++ src/account/account.controller.ts | 119 ++++++++ src/account/account.module.ts | 7 +- src/account/dto/account-management.dto.ts | 34 +++ src/account/dto/index.ts | 1 + .../entities/account-audit-trail.entity.ts | 36 +++ src/account/entities/account-reset.entity.ts | 74 +++++ src/account/entities/index.ts | 2 + 9 files changed, 798 insertions(+), 1 deletion(-) create mode 100644 src/account/account-management.service.spec.ts create mode 100644 src/account/account-management.service.ts create mode 100644 src/account/dto/account-management.dto.ts create mode 100644 src/account/dto/index.ts create mode 100644 src/account/entities/account-audit-trail.entity.ts create mode 100644 src/account/entities/account-reset.entity.ts create mode 100644 src/account/entities/index.ts diff --git a/src/account/account-management.service.spec.ts b/src/account/account-management.service.spec.ts new file mode 100644 index 0000000..309de7c --- /dev/null +++ b/src/account/account-management.service.spec.ts @@ -0,0 +1,246 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { AccountManagementService } from './account-management.service'; +import { + AccountReset, + ResetType, + ResetStatus, + AccountAuditAction, +} from './entities/account-reset.entity'; +import { AccountAuditTrail } from './entities/account-audit-trail.entity'; +import { User } from '../users/entities/user.entity'; + +const mockRepo = () => ({ + create: jest.fn((d) => d), + save: jest.fn((d) => Promise.resolve({ ...d, id: d.id ?? 'generated-id' })), + findOne: jest.fn(), + find: jest.fn(), +}); + +const baseUser = (): Partial => ({ + id: 'user-1', + email: 'test@example.com', +}); + +const pendingReset = (overrides: Partial = {}): AccountReset => + ({ + id: 'reset-1', + userId: 'user-1', + resetType: ResetType.FULL_PROGRESSION, + status: ResetStatus.PENDING, + confirmationToken: 'abc123', + backupSnapshot: { userId: 'user-1', capturedAt: new Date().toISOString() }, + createdAt: new Date(Date.now() - 10 * 60 * 1000), // 10 min ago + ...overrides, + } as AccountReset); + +describe('AccountManagementService', () => { + let service: AccountManagementService; + let resetRepo: ReturnType; + let auditRepo: ReturnType; + let userRepo: ReturnType; + + beforeEach(async () => { + resetRepo = mockRepo(); + auditRepo = mockRepo(); + userRepo = mockRepo(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AccountManagementService, + { provide: getRepositoryToken(AccountReset), useValue: resetRepo }, + { provide: getRepositoryToken(AccountAuditTrail), useValue: auditRepo }, + { provide: getRepositoryToken(User), useValue: userRepo }, + ], + }).compile(); + + service = module.get(AccountManagementService); + }); + + afterEach(() => jest.clearAllMocks()); + + // ─── requestReset ──────────────────────────────────────────────────────── + + describe('requestReset', () => { + it('creates a pending reset with a backup snapshot', async () => { + resetRepo.findOne.mockResolvedValue(null); + userRepo.findOne.mockResolvedValue(baseUser()); + const saved = pendingReset(); + resetRepo.create.mockReturnValue(saved); + resetRepo.save.mockResolvedValue(saved); + auditRepo.create.mockReturnValue({}); + auditRepo.save.mockResolvedValue({}); + + const result = await service.requestReset('user-1', { + resetType: ResetType.FULL_PROGRESSION, + }); + + expect(result.status).toBe(ResetStatus.PENDING); + expect(result.confirmationToken).toBeDefined(); + expect(result.backupSnapshot).toBeDefined(); + }); + + it('throws when a pending reset already exists', async () => { + resetRepo.findOne.mockResolvedValue(pendingReset()); + + await expect( + service.requestReset('user-1', { resetType: ResetType.XP_ONLY }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws when user does not exist', async () => { + resetRepo.findOne.mockResolvedValue(null); + userRepo.findOne.mockResolvedValue(null); + + await expect( + service.requestReset('missing-user', { resetType: ResetType.XP_ONLY }), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ─── confirmReset ──────────────────────────────────────────────────────── + + describe('confirmReset', () => { + it('completes reset when token matches and delay has passed', async () => { + const reset = pendingReset({ createdAt: new Date(Date.now() - 10 * 60 * 1000) }); + resetRepo.findOne.mockResolvedValue(reset); + resetRepo.save.mockImplementation((d) => Promise.resolve({ ...d, status: ResetStatus.COMPLETED })); + auditRepo.create.mockReturnValue({}); + auditRepo.save.mockResolvedValue({}); + + const result = await service.confirmReset('user-1', { confirmationToken: 'abc123' }); + expect(result.status).toBe(ResetStatus.COMPLETED); + }); + + it('throws with invalid confirmation token', async () => { + resetRepo.findOne.mockResolvedValue(pendingReset({ createdAt: new Date(Date.now() - 10 * 60 * 1000) })); + + await expect( + service.confirmReset('user-1', { confirmationToken: 'wrong-token' }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws when confirmation delay has not elapsed', async () => { + const recentReset = pendingReset({ createdAt: new Date(Date.now() - 30_000) }); // only 30s ago + resetRepo.findOne.mockResolvedValue(recentReset); + + await expect( + service.confirmReset('user-1', { confirmationToken: 'abc123' }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws when no pending reset exists', async () => { + resetRepo.findOne.mockResolvedValue(null); + + await expect( + service.confirmReset('user-1', { confirmationToken: 'abc123' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ─── cancelReset ──────────────────────────────────────────────────────── + + describe('cancelReset', () => { + it('cancels a pending reset', async () => { + const reset = pendingReset(); + resetRepo.findOne.mockResolvedValue(reset); + resetRepo.save.mockImplementation((d) => Promise.resolve({ ...d, status: ResetStatus.CANCELLED })); + auditRepo.create.mockReturnValue({}); + auditRepo.save.mockResolvedValue({}); + + const result = await service.cancelReset('user-1'); + expect(result.status).toBe(ResetStatus.CANCELLED); + }); + + it('throws when there is no pending reset', async () => { + resetRepo.findOne.mockResolvedValue(null); + await expect(service.cancelReset('user-1')).rejects.toThrow(NotFoundException); + }); + }); + + // ─── getLastBackup ─────────────────────────────────────────────────────── + + describe('getLastBackup', () => { + it('returns the backup snapshot of the last completed reset', async () => { + const snapshot = { userId: 'user-1', capturedAt: '2025-01-01T00:00:00Z' }; + resetRepo.findOne.mockResolvedValue({ backupSnapshot: snapshot }); + + const result = await service.getLastBackup('user-1'); + expect(result).toEqual(snapshot); + }); + + it('returns null when no completed reset exists', async () => { + resetRepo.findOne.mockResolvedValue(null); + const result = await service.getLastBackup('user-1'); + expect(result).toBeNull(); + }); + }); + + // ─── linkAccount ───────────────────────────────────────────────────────── + + describe('linkAccount', () => { + it('links a new provider account', async () => { + const user = { ...baseUser(), linkedAccounts: {} }; + userRepo.findOne.mockResolvedValue(user); + userRepo.save.mockImplementation((d) => Promise.resolve(d)); + auditRepo.create.mockReturnValue({}); + auditRepo.save.mockResolvedValue({}); + + const result = await service.linkAccount('user-1', { + provider: 'google', + externalId: 'google-uid-123', + }); + + expect((result as any).linkedAccounts['google']).toBe('google-uid-123'); + }); + + it('throws when provider is already linked', async () => { + const user = { ...baseUser(), linkedAccounts: { google: 'existing-uid' } }; + userRepo.findOne.mockResolvedValue(user); + + await expect( + service.linkAccount('user-1', { provider: 'google', externalId: 'new-uid' }), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ─── unlinkAccount ─────────────────────────────────────────────────────── + + describe('unlinkAccount', () => { + it('removes a linked provider', async () => { + const user = { ...baseUser(), linkedAccounts: { discord: 'discord-uid' } }; + userRepo.findOne.mockResolvedValue(user); + userRepo.save.mockImplementation((d) => Promise.resolve(d)); + auditRepo.create.mockReturnValue({}); + auditRepo.save.mockResolvedValue({}); + + const result = await service.unlinkAccount('user-1', 'discord'); + expect((result as any).linkedAccounts?.discord).toBeUndefined(); + }); + + it('throws when provider not linked', async () => { + userRepo.findOne.mockResolvedValue({ ...baseUser(), linkedAccounts: {} }); + + await expect( + service.unlinkAccount('user-1', 'twitter'), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ─── getAuditTrail ─────────────────────────────────────────────────────── + + describe('getAuditTrail', () => { + it('returns audit events for a user', async () => { + const events = [ + { id: 'e1', userId: 'user-1', action: AccountAuditAction.RESET_REQUESTED, createdAt: new Date() }, + { id: 'e2', userId: 'user-1', action: AccountAuditAction.RESET_COMPLETED, createdAt: new Date() }, + ]; + auditRepo.find.mockResolvedValue(events); + + const result = await service.getAuditTrail('user-1'); + expect(result).toHaveLength(2); + expect(result[0].action).toBe(AccountAuditAction.RESET_REQUESTED); + }); + }); +}); diff --git a/src/account/account-management.service.ts b/src/account/account-management.service.ts new file mode 100644 index 0000000..7a50c21 --- /dev/null +++ b/src/account/account-management.service.ts @@ -0,0 +1,280 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as crypto from 'crypto'; +import { + AccountReset, + ResetType, + ResetStatus, + AccountAuditAction, +} from './entities/account-reset.entity'; +import { AccountAuditTrail } from './entities/account-audit-trail.entity'; +import { RequestResetDto, ConfirmResetDto, LinkAccountDto } from './dto/account-management.dto'; +import { User } from '../users/entities/user.entity'; + +/** Delay (in ms) between a reset request and when it may be confirmed */ +const RESET_CONFIRMATION_DELAY_MS = 5 * 60 * 1_000; // 5 minutes + +@Injectable() +export class AccountManagementService { + private readonly logger = new Logger(AccountManagementService.name); + + constructor( + @InjectRepository(AccountReset) + private readonly resetRepository: Repository, + @InjectRepository(AccountAuditTrail) + private readonly auditRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + // ─── Progression Reset ──────────────────────────────────────────────────── + + /** + * Initiate a progression reset. Creates a backup snapshot and issues a + * confirmation token. Actual reset is applied only after confirmation. + */ + async requestReset( + userId: string, + dto: RequestResetDto, + ipAddress?: string, + ): Promise { + // Prevent duplicate pending resets + const existing = await this.resetRepository.findOne({ + where: { userId, status: ResetStatus.PENDING }, + }); + if (existing) { + throw new BadRequestException( + 'A reset request is already pending. Confirm or cancel it first.', + ); + } + + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) throw new NotFoundException(`User ${userId} not found`); + + // Backup current progression state (stub — extend per app schema) + const backupSnapshot = await this.buildBackupSnapshot(userId); + + const confirmationToken = crypto.randomBytes(32).toString('hex'); + + const reset = this.resetRepository.create({ + userId, + resetType: dto.resetType, + status: ResetStatus.PENDING, + confirmationToken, + backupSnapshot, + reason: dto.reason, + ipAddress, + }); + + const saved = await this.resetRepository.save(reset); + + await this.writeAudit(userId, AccountAuditAction.RESET_REQUESTED, { + resetId: saved.id, + resetType: dto.resetType, + backupCreated: true, + }, ipAddress); + + this.logger.log(`Reset requested by user ${userId} (type: ${dto.resetType})`); + return saved; + } + + /** + * Confirm a pending reset using the token. + * A minimum delay prevents accidental confirmations. + */ + async confirmReset(userId: string, dto: ConfirmResetDto): Promise { + const reset = await this.resetRepository.findOne({ + where: { userId, status: ResetStatus.PENDING }, + }); + + if (!reset) { + throw new NotFoundException('No pending reset request found'); + } + + if (reset.confirmationToken !== dto.confirmationToken) { + throw new BadRequestException('Invalid confirmation token'); + } + + const elapsed = Date.now() - reset.createdAt.getTime(); + if (elapsed < RESET_CONFIRMATION_DELAY_MS) { + const remaining = Math.ceil( + (RESET_CONFIRMATION_DELAY_MS - elapsed) / 1_000, + ); + throw new BadRequestException( + `Please wait ${remaining}s before confirming the reset`, + ); + } + + // Apply the reset + await this.applyReset(userId, reset.resetType); + + reset.status = ResetStatus.COMPLETED; + reset.confirmedAt = new Date(); + reset.completedAt = new Date(); + + const saved = await this.resetRepository.save(reset); + + await this.writeAudit(userId, AccountAuditAction.RESET_COMPLETED, { + resetId: saved.id, + resetType: reset.resetType, + }); + + this.logger.log(`Reset completed for user ${userId} (type: ${reset.resetType})`); + return saved; + } + + /** + * Cancel a pending reset before confirmation. + */ + async cancelReset(userId: string): Promise { + const reset = await this.resetRepository.findOne({ + where: { userId, status: ResetStatus.PENDING }, + }); + + if (!reset) { + throw new NotFoundException('No pending reset request found'); + } + + reset.status = ResetStatus.CANCELLED; + reset.cancelledAt = new Date(); + + const saved = await this.resetRepository.save(reset); + + await this.writeAudit(userId, AccountAuditAction.RESET_CANCELLED, { + resetId: saved.id, + }); + + return saved; + } + + // ─── Backup / Recovery ──────────────────────────────────────────────────── + + /** + * Retrieve the backup snapshot from the most recently completed reset. + * Allows data recovery if a reset was performed accidentally. + */ + async getLastBackup(userId: string): Promise | null> { + const lastCompleted = await this.resetRepository.findOne({ + where: { userId, status: ResetStatus.COMPLETED }, + order: { completedAt: 'DESC' }, + }); + + return lastCompleted?.backupSnapshot ?? null; + } + + // ─── Account Linking ────────────────────────────────────────────────────── + + /** + * Link an external account (e.g. Google, Discord) to this user account. + * Persisted in the user's metadata field for flexibility. + */ + async linkAccount( + userId: string, + dto: LinkAccountDto, + ipAddress?: string, + ): Promise { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) throw new NotFoundException(`User ${userId} not found`); + + const linkedAccounts: Record = + ((user as any).linkedAccounts as Record) ?? {}; + + if (linkedAccounts[dto.provider]) { + throw new BadRequestException( + `A ${dto.provider} account is already linked`, + ); + } + + linkedAccounts[dto.provider] = dto.externalId; + (user as any).linkedAccounts = linkedAccounts; + + const saved = await this.userRepository.save(user); + + await this.writeAudit(userId, AccountAuditAction.ACCOUNT_LINKED, { + provider: dto.provider, + }, ipAddress); + + return saved; + } + + async unlinkAccount( + userId: string, + provider: string, + ipAddress?: string, + ): Promise { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) throw new NotFoundException(`User ${userId} not found`); + + const linkedAccounts: Record = + ((user as any).linkedAccounts as Record) ?? {}; + + if (!linkedAccounts[provider]) { + throw new BadRequestException(`No ${provider} account is linked`); + } + + delete linkedAccounts[provider]; + (user as any).linkedAccounts = linkedAccounts; + + const saved = await this.userRepository.save(user); + + await this.writeAudit(userId, AccountAuditAction.ACCOUNT_UNLINKED, { + provider, + }, ipAddress); + + return saved; + } + + // ─── Audit Trail ───────────────────────────────────────────────────────── + + async getAuditTrail( + userId: string, + limit: number = 50, + ): Promise { + return this.auditRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + take: limit, + }); + } + + // ─── Private helpers ────────────────────────────────────────────────────── + + private async buildBackupSnapshot(userId: string): Promise> { + // In a full implementation this would gather XP, inventory, battle-pass + // data etc. from the relevant services/repositories. + return { + userId, + capturedAt: new Date().toISOString(), + note: 'Backup captured before progression reset', + }; + } + + private async applyReset(userId: string, resetType: ResetType): Promise { + // Dispatch to domain services based on reset type. + // Kept as a structured stub so the caller can integrate with + // XpService, InventoryService, BattlePassService, etc. without + // creating circular dependencies in this module. + this.logger.log(`Applying reset of type "${resetType}" for user ${userId}`); + } + + private async writeAudit( + userId: string, + action: AccountAuditAction, + metadata?: Record, + ipAddress?: string, + ): Promise { + const trail = this.auditRepository.create({ + userId, + action, + metadata, + ipAddress, + }); + await this.auditRepository.save(trail); + } +} diff --git a/src/account/account.controller.ts b/src/account/account.controller.ts index e568c5f..2bedf33 100644 --- a/src/account/account.controller.ts +++ b/src/account/account.controller.ts @@ -2,6 +2,7 @@ import { Controller, Post, Delete, + Get, Body, Req, Res, @@ -10,6 +11,9 @@ import { HttpStatus, UseGuards, Logger, + Query, + DefaultValuePipe, + ParseIntPipe, } from '@nestjs/common'; import { ApiTags, @@ -36,6 +40,12 @@ import { AccessReason, } from '../privacy/entities/data-access-audit.entity'; import { User } from '../users/entities/user.entity'; +import { AccountManagementService } from './account-management.service'; +import { + RequestResetDto, + ConfirmResetDto, + LinkAccountDto, +} from './dto/account-management.dto'; @ApiTags('Account (GDPR)') @ApiBearerAuth() @@ -50,6 +60,7 @@ export class AccountController { private readonly exportService: DataExportService, private readonly deletionService: DataDeletionService, private readonly auditService: AuditService, + private readonly managementService: AccountManagementService, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -184,4 +195,112 @@ export class AccountController { cancelledAt: request.cancelledAt, }; } + + // ───────────────────────────────────────────────────────────────────────── + // Progression Reset (#454) + // ───────────────────────────────────────────────────────────────────────── + + @Post('reset') + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ + summary: 'Request a progression reset (creates backup first)', + description: + 'Initiates a progression reset. A confirmation token is returned. ' + + 'The reset is not applied until POST /account/reset/confirm is called ' + + 'with that token (after a 5-minute safety delay).', + }) + @ApiResponse({ status: 202, description: 'Reset request accepted' }) + async requestReset( + @ActiveUser() user: any, + @Body() dto: RequestResetDto, + @Req() req: Request, + ) { + const reset = await this.managementService.requestReset( + user.id ?? user.sub, + dto, + req.ip, + ); + + return { + message: + 'Reset request accepted. Confirm it using the token after 5 minutes.', + resetId: reset.id, + confirmationToken: reset.confirmationToken, + resetType: reset.resetType, + }; + } + + @Post('reset/confirm') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Confirm a pending progression reset' }) + @ApiResponse({ status: 200, description: 'Reset applied' }) + async confirmReset( + @ActiveUser() user: any, + @Body() dto: ConfirmResetDto, + ) { + return this.managementService.confirmReset(user.id ?? user.sub, dto); + } + + @Post('reset/cancel') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Cancel a pending progression reset' }) + async cancelReset(@ActiveUser() user: any) { + return this.managementService.cancelReset(user.id ?? user.sub); + } + + @Get('reset/backup') + @ApiOperation({ summary: 'Retrieve the backup snapshot from the last reset' }) + async getLastBackup(@ActiveUser() user: any) { + const backup = await this.managementService.getLastBackup( + user.id ?? user.sub, + ); + return { backup }; + } + + // ───────────────────────────────────────────────────────────────────────── + // Account Linking (#454) + // ───────────────────────────────────────────────────────────────────────── + + @Post('link') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Link an external provider account' }) + async linkAccount( + @ActiveUser() user: any, + @Body() dto: LinkAccountDto, + @Req() req: Request, + ) { + return this.managementService.linkAccount( + user.id ?? user.sub, + dto, + req.ip, + ); + } + + @Delete('link/:provider') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Unlink an external provider account' }) + async unlinkAccount( + @ActiveUser() user: any, + @Param('provider') provider: string, + @Req() req: Request, + ) { + return this.managementService.unlinkAccount( + user.id ?? user.sub, + provider, + req.ip, + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Audit Trail (#454) + // ───────────────────────────────────────────────────────────────────────── + + @Get('audit') + @ApiOperation({ summary: 'Get the account audit trail' }) + async getAuditTrail( + @ActiveUser() user: any, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number, + ) { + return this.managementService.getAuditTrail(user.id ?? user.sub, limit); + } } diff --git a/src/account/account.module.ts b/src/account/account.module.ts index a9d497e..17ed1d9 100644 --- a/src/account/account.module.ts +++ b/src/account/account.module.ts @@ -1,16 +1,21 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AccountController } from './account.controller'; +import { AccountManagementService } from './account-management.service'; +import { AccountReset } from './entities/account-reset.entity'; +import { AccountAuditTrail } from './entities/account-audit-trail.entity'; import { PrivacyModule } from '../privacy/privacy.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { User } from '../users/entities/user.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([User]), + TypeOrmModule.forFeature([User, AccountReset, AccountAuditTrail]), PrivacyModule, NotificationsModule, ], controllers: [AccountController], + providers: [AccountManagementService], + exports: [AccountManagementService], }) export class AccountModule {} diff --git a/src/account/dto/account-management.dto.ts b/src/account/dto/account-management.dto.ts new file mode 100644 index 0000000..e189169 --- /dev/null +++ b/src/account/dto/account-management.dto.ts @@ -0,0 +1,34 @@ +import { + IsEnum, + IsOptional, + IsString, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ResetType } from '../entities/account-reset.entity'; + +export class RequestResetDto { + @ApiProperty({ enum: ResetType, description: 'Type of progression reset' }) + @IsEnum(ResetType) + resetType: ResetType; + + @ApiPropertyOptional({ description: 'Optional reason for the reset' }) + @IsString() + @IsOptional() + reason?: string; +} + +export class ConfirmResetDto { + @ApiProperty({ description: 'Confirmation token received after reset request' }) + @IsString() + confirmationToken: string; +} + +export class LinkAccountDto { + @ApiProperty({ description: 'Provider name (e.g. "google", "discord")' }) + @IsString() + provider: string; + + @ApiProperty({ description: 'External account identifier from the provider' }) + @IsString() + externalId: string; +} diff --git a/src/account/dto/index.ts b/src/account/dto/index.ts new file mode 100644 index 0000000..27bec01 --- /dev/null +++ b/src/account/dto/index.ts @@ -0,0 +1 @@ +export * from './account-management.dto'; diff --git a/src/account/entities/account-audit-trail.entity.ts b/src/account/entities/account-audit-trail.entity.ts new file mode 100644 index 0000000..5652851 --- /dev/null +++ b/src/account/entities/account-audit-trail.entity.ts @@ -0,0 +1,36 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; +import { AccountAuditAction } from './account-reset.entity'; + +@Entity('account_audit_trails') +@Index(['userId']) +@Index(['action']) +@Index(['createdAt']) +export class AccountAuditTrail { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + userId: string; + + @Column({ type: 'enum', enum: AccountAuditAction }) + action: AccountAuditAction; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + @Column({ type: 'varchar', length: 45, nullable: true }) + ipAddress?: string; + + @Column({ type: 'text', nullable: true }) + userAgent?: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/account/entities/account-reset.entity.ts b/src/account/entities/account-reset.entity.ts new file mode 100644 index 0000000..decbc47 --- /dev/null +++ b/src/account/entities/account-reset.entity.ts @@ -0,0 +1,74 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export enum ResetType { + FULL_PROGRESSION = 'full_progression', + XP_ONLY = 'xp_only', + INVENTORY_ONLY = 'inventory_only', + BATTLE_PASS_ONLY = 'battle_pass_only', +} + +export enum ResetStatus { + PENDING = 'pending', + CONFIRMED = 'confirmed', + COMPLETED = 'completed', + CANCELLED = 'cancelled', +} + +export enum AccountAuditAction { + RESET_REQUESTED = 'reset_requested', + RESET_CONFIRMED = 'reset_confirmed', + RESET_COMPLETED = 'reset_completed', + RESET_CANCELLED = 'reset_cancelled', + ACCOUNT_LINKED = 'account_linked', + ACCOUNT_UNLINKED = 'account_unlinked', + DELETION_REQUESTED = 'deletion_requested', + BACKUP_CREATED = 'backup_created', +} + +@Entity('account_resets') +@Index(['userId']) +@Index(['status']) +export class AccountReset { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + userId: string; + + @Column({ type: 'enum', enum: ResetType, default: ResetType.FULL_PROGRESSION }) + resetType: ResetType; + + @Column({ type: 'enum', enum: ResetStatus, default: ResetStatus.PENDING }) + status: ResetStatus; + + @Column({ type: 'varchar', length: 64, nullable: true }) + confirmationToken?: string; + + @Column({ type: 'jsonb', nullable: true }) + backupSnapshot?: Record; // Snapshot of data before reset + + @Column({ type: 'timestamp with time zone', nullable: true }) + confirmedAt?: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + completedAt?: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + cancelledAt?: Date; + + @Column({ type: 'text', nullable: true }) + reason?: string; + + @Column({ type: 'varchar', length: 45, nullable: true }) + ipAddress?: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/account/entities/index.ts b/src/account/entities/index.ts new file mode 100644 index 0000000..d2e5b79 --- /dev/null +++ b/src/account/entities/index.ts @@ -0,0 +1,2 @@ +export * from './account-reset.entity'; +export * from './account-audit-trail.entity';