diff --git a/backend/src/leadership/contribution-leaderboard.module.ts b/backend/src/leadership/contribution-leaderboard.module.ts new file mode 100644 index 0000000..c9b48b3 --- /dev/null +++ b/backend/src/leadership/contribution-leaderboard.module.ts @@ -0,0 +1,65 @@ +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { ContributionLeaderboardController } from "./controllers/contribution-leaderboard.controller" +import { ContributionLeaderboardService } from "./services/contribution-leaderboard.service" +import { ContributionService } from "./services/contribution.service" +import { UserContributionService } from "./services/user-contribution.service" +import { ContributionEntity } from "./entities/contribution.entity" +import { UserContributionEntity } from "./entities/user-contribution.entity" +import { ContributionTypeEntity } from "./entities/contribution-type.entity" +import { ContributionRepository } from "./repositories/contribution.repository" +import { UserContributionRepository } from "./repositories/user-contribution.repository" +import { ContributionTypeRepository } from "./repositories/contribution-type.repository" +import { CacheModule } from "@nestjs/cache-manager" +import { ScheduleModule } from "@nestjs/schedule" +import { ContributionCacheService } from "./services/contribution-cache.service" +import { ContributionSchedulerService } from "./services/contribution-scheduler.service" +import { ContributionEventEmitter } from "./events/contribution-event.emitter" +import { ContributionEventListener } from "./events/contribution-event.listener" +import { ContributionMetricsService } from "./services/contribution-metrics.service" +import { ContributionLoggerService } from "./services/contribution-logger.service" +import { ContributionValidationService } from "./services/contribution-validation.service" +import { ContributionAchievementService } from "./services/contribution-achievement.service" +import { AchievementEntity } from "./entities/achievement.entity" +import { UserAchievementEntity } from "./entities/user-achievement.entity" +import { AchievementRepository } from "./repositories/achievement.repository" +import { UserAchievementRepository } from "./repositories/user-achievement.repository" + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + ContributionEntity, + UserContributionEntity, + ContributionTypeEntity, + AchievementEntity, + UserAchievementEntity, + ]), + CacheModule.register({ + ttl: 60 * 60 * 1000, // 1 hour cache + max: 100, // maximum number of items in cache + }), + ScheduleModule.forRoot(), + ], + controllers: [ContributionLeaderboardController], + providers: [ + ContributionLeaderboardService, + ContributionService, + UserContributionService, + ContributionRepository, + UserContributionRepository, + ContributionTypeRepository, + ContributionCacheService, + ContributionSchedulerService, + ContributionEventEmitter, + ContributionEventListener, + ContributionMetricsService, + ContributionLoggerService, + ContributionValidationService, + ContributionAchievementService, + AchievementRepository, + UserAchievementRepository, + ], + exports: [ContributionLeaderboardService, ContributionService, UserContributionService], +}) +export class ContributionLeaderboardModule {} + diff --git a/backend/src/leadership/controllers/contribution-leaderboard.controller.ts b/backend/src/leadership/controllers/contribution-leaderboard.controller.ts new file mode 100644 index 0000000..6e6d9c2 --- /dev/null +++ b/backend/src/leadership/controllers/contribution-leaderboard.controller.ts @@ -0,0 +1,119 @@ +import { + Controller, + Get, + Post, + Body, + Query, + Param, + UseInterceptors, + CacheInterceptor, + HttpStatus, + HttpCode, + ValidationPipe, + ParseIntPipe, + DefaultValuePipe, + Logger, +} from "@nestjs/common" +import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam, ApiBody } from "@nestjs/swagger" +import type { ContributionLeaderboardService } from "../services/contribution-leaderboard.service" +import type { GetLeaderboardDto } from "../dto/get-leaderboard.dto" +import { CreateContributionDto } from "../dto/create-contribution.dto" +import { LeaderboardResponseDto } from "../dto/leaderboard-response.dto" +import { TimeRangeEnum } from "../enums/time-range.enum" +import { ContributionTypeEnum } from "../enums/contribution-type.enum" +import type { ContributionEventEmitter } from "../events/contribution-event.emitter" +import type { ContributionLoggerService } from "../services/contribution-logger.service" + +@ApiTags("Contribution Leaderboard") +@Controller("contribution-leaderboard") +export class ContributionLeaderboardController { + private readonly logger = new Logger(ContributionLeaderboardController.name) + + constructor( + private readonly leaderboardService: ContributionLeaderboardService, + private readonly eventEmitter: ContributionEventEmitter, + private readonly loggerService: ContributionLoggerService, + ) {} + + @Get() + @UseInterceptors(CacheInterceptor) + @ApiOperation({ summary: "Get contribution leaderboard" }) + @ApiQuery({ name: "timeRange", enum: TimeRangeEnum, required: false }) + @ApiQuery({ name: "contributionType", enum: ContributionTypeEnum, required: false }) + @ApiQuery({ name: "page", type: Number, required: false }) + @ApiQuery({ name: "limit", type: Number, required: false }) + @ApiResponse({ status: 200, description: "Return leaderboard data", type: LeaderboardResponseDto }) + async getLeaderboard( + @Query('timeRange', new DefaultValuePipe(TimeRangeEnum.ALL_TIME)) timeRange: TimeRangeEnum, + @Query('contributionType') contributionType: ContributionTypeEnum, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ): Promise { + this.logger.log( + `Getting leaderboard for timeRange: ${timeRange}, contributionType: ${contributionType}, page: ${page}, limit: ${limit}`, + ) + + const dto: GetLeaderboardDto = { + timeRange, + contributionType, + pagination: { page, limit }, + } + + this.loggerService.logLeaderboardRequest(dto) + + return this.leaderboardService.getLeaderboard(dto) + } + + @Post('contributions') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Record a new contribution' }) + @ApiBody({ type: CreateContributionDto }) + @ApiResponse({ status: 201, description: 'Contribution recorded successfully' }) + async recordContribution( + @Body(new ValidationPipe({ transform: true })) createContributionDto: CreateContributionDto, + ): Promise { + this.logger.log(`Recording contribution for user: ${createContributionDto.userId}, type: ${createContributionDto.type}`); + + await this.leaderboardService.recordContribution(createContributionDto); + + // Emit event for other parts of the system + this.eventEmitter.emitContributionCreated(createContributionDto); + + this.loggerService.logContributionCreated(createContributionDto); + } + + @Get("users/:userId/contributions") + @ApiOperation({ summary: "Get contributions for a specific user" }) + @ApiParam({ name: "userId", type: String }) + @ApiQuery({ name: "timeRange", enum: TimeRangeEnum, required: false }) + @ApiQuery({ name: "page", type: Number, required: false }) + @ApiQuery({ name: "limit", type: Number, required: false }) + async getUserContributions( + @Param('userId') userId: string, + @Query('timeRange', new DefaultValuePipe(TimeRangeEnum.ALL_TIME)) timeRange: TimeRangeEnum, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ) { + this.logger.log( + `Getting contributions for user: ${userId}, timeRange: ${timeRange}, page: ${page}, limit: ${limit}`, + ) + + return this.leaderboardService.getUserContributions(userId, timeRange, { page, limit }) + } + + @Get("contribution-types") + @ApiOperation({ summary: "Get all contribution types" }) + async getContributionTypes() { + return this.leaderboardService.getContributionTypes() + } + + @Get('statistics') + @ApiOperation({ summary: 'Get contribution statistics' }) + @ApiQuery({ name: 'timeRange', enum: TimeRangeEnum, required: false }) + async getStatistics( + @Query('timeRange', new DefaultValuePipe(TimeRangeEnum.ALL_TIME)) timeRange: TimeRangeEnum, + ) { + return this.leaderboardService.getStatistics(timeRange); + } +} + diff --git a/backend/src/leadership/dto/create-contribution.dto.ts b/backend/src/leadership/dto/create-contribution.dto.ts new file mode 100644 index 0000000..af69d9a --- /dev/null +++ b/backend/src/leadership/dto/create-contribution.dto.ts @@ -0,0 +1,21 @@ +import { IsNotEmpty, IsString, IsOptional, IsNumber, IsObject, Min } from "class-validator" + +export class CreateContributionDto { + @IsNotEmpty() + @IsString() + userId: string + + @IsNotEmpty() + @IsString() + type: string + + @IsOptional() + @IsNumber() + @Min(0) + points?: number + + @IsOptional() + @IsObject() + metadata?: Record +} + diff --git a/backend/src/leadership/dto/get-leaderboard.dto.ts b/backend/src/leadership/dto/get-leaderboard.dto.ts new file mode 100644 index 0000000..93e9c0c --- /dev/null +++ b/backend/src/leadership/dto/get-leaderboard.dto.ts @@ -0,0 +1,20 @@ +import { IsEnum, IsOptional, ValidateNested } from "class-validator" +import { Type } from "class-transformer" +import { TimeRangeEnum } from "../enums/time-range.enum" +import { ContributionTypeEnum } from "../enums/contribution-type.enum" +import { PaginationDto } from "./pagination.dto" + +export class GetLeaderboardDto { + @IsEnum(TimeRangeEnum) + @IsOptional() + timeRange: TimeRangeEnum = TimeRangeEnum.ALL_TIME + + @IsEnum(ContributionTypeEnum) + @IsOptional() + contributionType?: ContributionTypeEnum + + @ValidateNested() + @Type(() => PaginationDto) + pagination: PaginationDto +} + diff --git a/backend/src/leadership/dto/leaderboard-entry.dto.ts b/backend/src/leadership/dto/leaderboard-entry.dto.ts new file mode 100644 index 0000000..702c048 --- /dev/null +++ b/backend/src/leadership/dto/leaderboard-entry.dto.ts @@ -0,0 +1,20 @@ +export class LeaderboardEntryDto { + rank: number + userId: string + username: string + avatarUrl?: string + totalPoints: number + contributionCounts: { + submissions: number + edits: number + approvals: number + total: number + } + achievements?: Array<{ + id: string + name: string + icon: string + }> + lastContributionDate: Date +} + diff --git a/backend/src/leadership/dto/leaderboard-response.dto.ts b/backend/src/leadership/dto/leaderboard-response.dto.ts new file mode 100644 index 0000000..8dd7f39 --- /dev/null +++ b/backend/src/leadership/dto/leaderboard-response.dto.ts @@ -0,0 +1,16 @@ +import type { TimeRangeEnum } from "../enums/time-range.enum" +import type { ContributionTypeEnum } from "../enums/contribution-type.enum" +import type { LeaderboardEntryDto } from "./leaderboard-entry.dto" + +export class LeaderboardResponseDto { + timeRange: TimeRangeEnum + contributionType?: ContributionTypeEnum + pagination: { + page: number + limit: number + totalItems: number + totalPages: number + } + entries: LeaderboardEntryDto[] +} + diff --git a/backend/src/leadership/dto/pagination.dto.ts b/backend/src/leadership/dto/pagination.dto.ts new file mode 100644 index 0000000..fad0b06 --- /dev/null +++ b/backend/src/leadership/dto/pagination.dto.ts @@ -0,0 +1,18 @@ +import { IsNumber, IsOptional, Min, Max } from "class-validator" +import { Type } from "class-transformer" + +export class PaginationDto { + @IsNumber() + @IsOptional() + @Min(1) + @Type(() => Number) + page = 1 + + @IsNumber() + @IsOptional() + @Min(1) + @Max(100) + @Type(() => Number) + limit = 10 +} + diff --git a/backend/src/leadership/entities/achievement.entity.ts b/backend/src/leadership/entities/achievement.entity.ts new file mode 100644 index 0000000..fe0fd64 --- /dev/null +++ b/backend/src/leadership/entities/achievement.entity.ts @@ -0,0 +1,29 @@ +import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn } from "typeorm" + +@Entity("contribution_leaderboard_achievements") +export class AchievementEntity { + @PrimaryColumn() + id: string + + @Column() + name: string + + @Column() + description: string + + @Column({ nullable: true }) + icon: string + + @Column({ type: "int" }) + threshold: number + + @Column() + type: string + + @CreateDateColumn({ name: "created_at" }) + createdAt: Date + + @UpdateDateColumn({ name: "updated_at" }) + updatedAt: Date +} + diff --git a/backend/src/leadership/entities/contribution-type.entity.ts b/backend/src/leadership/entities/contribution-type.entity.ts new file mode 100644 index 0000000..1e8b6cd --- /dev/null +++ b/backend/src/leadership/entities/contribution-type.entity.ts @@ -0,0 +1,26 @@ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from "typeorm" + +@Entity("contribution_leaderboard_contribution_types") +export class ContributionTypeEntity { + @PrimaryGeneratedColumn("uuid") + id: string + + @Column({ unique: true }) + name: string + + @Column({ type: "int", default: 1 }) + defaultPoints: number + + @Column({ nullable: true }) + description: string + + @Column({ nullable: true }) + icon: string + + @CreateDateColumn({ name: "created_at" }) + createdAt: Date + + @UpdateDateColumn({ name: "updated_at" }) + updatedAt: Date +} + diff --git a/backend/src/leadership/entities/contribution.entity.ts b/backend/src/leadership/entities/contribution.entity.ts new file mode 100644 index 0000000..f173236 --- /dev/null +++ b/backend/src/leadership/entities/contribution.entity.ts @@ -0,0 +1,29 @@ +import { Entity, Column, PrimaryGeneratedColumn, Index, CreateDateColumn, UpdateDateColumn } from "typeorm" + +@Entity("contribution_leaderboard_contributions") +export class ContributionEntity { + @PrimaryGeneratedColumn("uuid") + id: string + + @Column({ name: "user_id" }) + @Index() + userId: string + + @Column({ name: "contribution_type_id" }) + @Index() + contributionTypeId: string + + @Column({ type: "int", default: 1 }) + points: number + + @Column({ type: "jsonb", default: {} }) + metadata: Record + + @CreateDateColumn({ name: "created_at" }) + @Index() + createdAt: Date + + @UpdateDateColumn({ name: "updated_at" }) + updatedAt: Date +} + diff --git a/backend/src/leadership/entities/user-achievement.entity.ts b/backend/src/leadership/entities/user-achievement.entity.ts new file mode 100644 index 0000000..b48e3cd --- /dev/null +++ b/backend/src/leadership/entities/user-achievement.entity.ts @@ -0,0 +1,22 @@ +import { Entity, Column, PrimaryGeneratedColumn, Index, CreateDateColumn } from "typeorm" + +@Entity("contribution_leaderboard_user_achievements") +export class UserAchievementEntity { + @PrimaryGeneratedColumn("uuid") + id: string + + @Column({ name: "user_id" }) + @Index() + userId: string + + @Column({ name: "achievement_id" }) + @Index() + achievementId: string + + @Column({ name: "awarded_at", type: "timestamp" }) + awardedAt: Date + + @CreateDateColumn({ name: "created_at" }) + createdAt: Date +} + diff --git a/backend/src/leadership/entities/user-contribution.entity.ts b/backend/src/leadership/entities/user-contribution.entity.ts new file mode 100644 index 0000000..36875a0 --- /dev/null +++ b/backend/src/leadership/entities/user-contribution.entity.ts @@ -0,0 +1,38 @@ +import { Entity, Column, PrimaryGeneratedColumn, Index, CreateDateColumn, UpdateDateColumn } from "typeorm" + +@Entity("contribution_leaderboard_user_contributions") +export class UserContributionEntity { + @PrimaryGeneratedColumn("uuid") + id: string + + @Column({ name: "user_id", unique: true }) + @Index() + userId: string + + @Column({ name: "total_points", type: "int", default: 0 }) + @Index() + totalPoints: number + + @Column({ name: "submission_count", type: "int", default: 0 }) + submissionCount: number + + @Column({ name: "edit_count", type: "int", default: 0 }) + editCount: number + + @Column({ name: "approval_count", type: "int", default: 0 }) + approvalCount: number + + @Column({ name: "comment_count", type: "int", default: 0 }) + commentCount: number + + @Column({ name: "last_contribution_date", type: "timestamp", nullable: true }) + @Index() + lastContributionDate: Date + + @CreateDateColumn({ name: "created_at" }) + createdAt: Date + + @UpdateDateColumn({ name: "updated_at" }) + updatedAt: Date +} + diff --git a/backend/src/leadership/enums/contribution-type.enum.ts b/backend/src/leadership/enums/contribution-type.enum.ts new file mode 100644 index 0000000..f6f7613 --- /dev/null +++ b/backend/src/leadership/enums/contribution-type.enum.ts @@ -0,0 +1,7 @@ +export enum ContributionTypeEnum { + SUBMISSION = "submission", + EDIT = "edit", + APPROVAL = "approval", + COMMENT = "comment", +} + diff --git a/backend/src/leadership/enums/time-range.enum.ts b/backend/src/leadership/enums/time-range.enum.ts new file mode 100644 index 0000000..aab6fb9 --- /dev/null +++ b/backend/src/leadership/enums/time-range.enum.ts @@ -0,0 +1,7 @@ +export enum TimeRangeEnum { + WEEKLY = "weekly", + MONTHLY = "monthly", + YEARLY = "yearly", + ALL_TIME = "all-time", +} + diff --git a/backend/src/leadership/events/contribution-event.emitter.ts b/backend/src/leadership/events/contribution-event.emitter.ts new file mode 100644 index 0000000..089cd0e --- /dev/null +++ b/backend/src/leadership/events/contribution-event.emitter.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@nestjs/common" +import type { EventEmitter2 } from "@nestjs/event-emitter" +import type { CreateContributionDto } from "../dto/create-contribution.dto" +import type { AchievementEntity } from "../entities/achievement.entity" + +@Injectable() +export class ContributionEventEmitter { + constructor(private readonly eventEmitter: EventEmitter2) {} + + emitContributionCreated(contribution: CreateContributionDto): void { + this.eventEmitter.emit("contribution.created", contribution) + } + + emitAchievementAwarded(userId: string, achievement: AchievementEntity): void { + this.eventEmitter.emit("achievement.awarded", { userId, achievement }) + } + + emitLeaderboardUpdated(): void { + this.eventEmitter.emit("leaderboard.updated") + } + + emitUserRankChanged(userId: string, oldRank: number, newRank: number): void { + \ + this.eventEmitter.emit('user.rank.changed\', { userI oldRank: number, newRank: number): void { + this.eventEmitter.emit('user.rank.changed', + userId, oldRank, newRank + ) + } +} + diff --git a/backend/src/leadership/events/contribution-event.listener.ts b/backend/src/leadership/events/contribution-event.listener.ts new file mode 100644 index 0000000..9288458 --- /dev/null +++ b/backend/src/leadership/events/contribution-event.listener.ts @@ -0,0 +1,52 @@ +import { Injectable, Logger } from "@nestjs/common" +import { OnEvent } from "@nestjs/event-emitter" +import type { CreateContributionDto } from "../dto/create-contribution.dto" +import type { ContributionCacheService } from "../services/contribution-cache.service" +import type { ContributionLoggerService } from "../services/contribution-logger.service" + +@Injectable() +export class ContributionEventListener { + private readonly logger = new Logger(ContributionEventListener.name) + + constructor( + private readonly cacheService: ContributionCacheService, + private readonly loggerService: ContributionLoggerService, + ) {} + + @OnEvent("contribution.created") + handleContributionCreatedEvent(payload: CreateContributionDto): void { + this.logger.log(`Handling contribution.created event for user: ${payload.userId}`) + + // Invalidate cache when a new contribution is created + this.cacheService.invalidateLeaderboardCache() + } + + @OnEvent("achievement.awarded") + handleAchievementAwardedEvent(payload: { userId: string; achievement: any }): void { + this.logger.log( + `Handling achievement.awarded event for user: ${payload.userId}, achievement: ${payload.achievement.name}`, + ) + + // Log achievement + this.loggerService.logAchievementAwarded(payload.userId, payload.achievement.id, payload.achievement.name) + } + + @OnEvent("leaderboard.updated") + handleLeaderboardUpdatedEvent(): void { + this.logger.log("Handling leaderboard.updated event") + + // Invalidate cache when leaderboard is updated + this.cacheService.invalidateLeaderboardCache() + } + + @OnEvent("user.rank.changed") + handleUserRankChangedEvent(payload: { userId: string; oldRank: number; newRank: number }): void { + this.logger.log( + `Handling user.rank.changed event for user: ${payload.userId}, oldRank: ${payload.oldRank}, newRank: ${payload.newRank}`, + ) + + // Log rank change + this.loggerService.logUserRankChanged(payload.userId, payload.oldRank, payload.newRank) + } +} + diff --git a/backend/src/leadership/repositories/achievement.repository.ts b/backend/src/leadership/repositories/achievement.repository.ts new file mode 100644 index 0000000..0370baa --- /dev/null +++ b/backend/src/leadership/repositories/achievement.repository.ts @@ -0,0 +1,25 @@ +import { Injectable } from "@nestjs/common" +import { InjectRepository } from "@nestjs/typeorm" +import type { Repository } from "typeorm" +import { AchievementEntity } from "../entities/achievement.entity" + +@Injectable() +export class AchievementRepository { + constructor( + @InjectRepository(AchievementEntity) + private readonly repository: Repository, + ) {} + + async save(achievement: AchievementEntity): Promise { + return this.repository.save(achievement) + } + + async findById(id: string): Promise { + return this.repository.findOne({ where: { id } }) + } + + async find(): Promise { + return this.repository.find() + } +} + diff --git a/backend/src/leadership/repositories/contribution-type.repository.ts b/backend/src/leadership/repositories/contribution-type.repository.ts new file mode 100644 index 0000000..fe715c0 --- /dev/null +++ b/backend/src/leadership/repositories/contribution-type.repository.ts @@ -0,0 +1,44 @@ +import { Injectable } from "@nestjs/common" +import { InjectRepository } from "@nestjs/typeorm" +import type { Repository } from "typeorm" +import { ContributionTypeEntity } from "../entities/contribution-type.entity" + +@Injectable() +export class ContributionTypeRepository { + constructor( + @InjectRepository(ContributionTypeEntity) + private readonly repository: Repository, + ) {} + + async save(contributionType: ContributionTypeEntity): Promise { + return this.repository.save(contributionType) + } + + async findOne(id: string): Promise { + return this.repository.findOne({ where: { id } }) + } + + async findByName(name: string): Promise { + return this.repository.findOne({ where: { name } }) + } + + async find(): Promise { + return this.repository.find() + } + + async createContributionType( + name: string, + defaultPoints: number, + description?: string, + icon?: string, + ): Promise { + const contributionType = new ContributionTypeEntity() + contributionType.name = name + contributionType.defaultPoints = defaultPoints + contributionType.description = description + contributionType.icon = icon + + return this.repository.save(contributionType) + } +} + diff --git a/backend/src/leadership/repositories/contribution.repository.ts b/backend/src/leadership/repositories/contribution.repository.ts new file mode 100644 index 0000000..d2ff879 --- /dev/null +++ b/backend/src/leadership/repositories/contribution.repository.ts @@ -0,0 +1,88 @@ +import { Injectable } from "@nestjs/common" +import { InjectRepository } from "@nestjs/typeorm" +import { type Repository, Between } from "typeorm" +import { ContributionEntity } from "../entities/contribution.entity" +import type { PaginationDto } from "../dto/pagination.dto" + +@Injectable() +export class ContributionRepository { + constructor( + @InjectRepository(ContributionEntity) + private readonly repository: Repository, + ) {} + + async save(contribution: ContributionEntity): Promise { + return this.repository.save(contribution) + } + + async findOne(id: string): Promise { + return this.repository.findOne({ where: { id } }) + } + + async findByTypeAndDateRange(contributionTypeId: string, startDate: Date, endDate: Date, pagination: PaginationDto) { + const [items, totalItems] = await this.repository.findAndCount({ + where: { + contributionTypeId, + createdAt: Between(startDate, endDate), + }, + skip: (pagination.page - 1) * pagination.limit, + take: pagination.limit, + order: { + createdAt: "DESC", + }, + }) + + return { items, totalItems } + } + + async findUserContributions(userId: string, startDate: Date, endDate: Date, pagination: PaginationDto) { + const [items, totalItems] = await this.repository.findAndCount({ + where: { + userId, + createdAt: Between(startDate, endDate), + }, + skip: (pagination.page - 1) * pagination.limit, + take: pagination.limit, + order: { + createdAt: "DESC", + }, + }) + + return { items, totalItems } + } + + async countByDateRange(startDate: Date, endDate: Date): Promise { + return this.repository.count({ + where: { + createdAt: Between(startDate, endDate), + }, + }) + } + + async countByTypeAndDateRange(startDate: Date, endDate: Date): Promise> { + const result = await this.repository + .createQueryBuilder("contribution") + .select("contribution.contributionTypeId", "typeId") + .addSelect("COUNT(*)", "count") + .where("contribution.createdAt BETWEEN :startDate AND :endDate", { startDate, endDate }) + .groupBy("contribution.contributionTypeId") + .getRawMany() + + return result.reduce((acc, item) => { + acc[item.typeId] = Number.parseInt(item.count, 10) + return acc + }, {}) + } + + async sumPointsByUserAndDateRange(userId: string, startDate: Date, endDate: Date): Promise { + const result = await this.repository + .createQueryBuilder("contribution") + .select("SUM(contribution.points)", "totalPoints") + .where("contribution.userId = :userId", { userId }) + .andWhere("contribution.createdAt BETWEEN :startDate AND :endDate", { startDate, endDate }) + .getRawOne() + + return Number.parseInt(result.totalPoints, 10) || 0 + } +} + diff --git a/backend/src/leadership/repositories/user-achievement.repository.ts b/backend/src/leadership/repositories/user-achievement.repository.ts new file mode 100644 index 0000000..598edce --- /dev/null +++ b/backend/src/leadership/repositories/user-achievement.repository.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@nestjs/common" +import { InjectRepository } from "@nestjs/typeorm" +import type { Repository } from "typeorm" +import { UserAchievementEntity } from "../entities/user-achievement.entity" + +@Injectable() +export class UserAchievementRepository { + constructor( + @InjectRepository(UserAchievementEntity) + private readonly repository: Repository, + ) {} + + async save(userAchievement: UserAchievementEntity): Promise { + return this.repository.save(userAchievement) + } + + async findByUserId(userId: string): Promise { + return this.repository.find({ where: { userId } }) + } + + async findByUserIdAndAchievementId(userId: string, achievementId: string): Promise { + return this.repository.findOne({ + where: { + userId, + achievementId, + }, + }) + } +} + diff --git a/backend/src/leadership/repositories/user-contribution.repository.ts b/backend/src/leadership/repositories/user-contribution.repository.ts new file mode 100644 index 0000000..f32b826 --- /dev/null +++ b/backend/src/leadership/repositories/user-contribution.repository.ts @@ -0,0 +1,120 @@ +import { Injectable } from "@nestjs/common" +import { InjectRepository } from "@nestjs/typeorm" +import { type Repository, MoreThan } from "typeorm" +import { UserContributionEntity } from "../entities/user-contribution.entity" +import { ContributionTypeEnum } from "../enums/contribution-type.enum" +import type { PaginationDto } from "../dto/pagination.dto" + +@Injectable() +export class UserContributionRepository { + constructor( + @InjectRepository(UserContributionEntity) + private readonly repository: Repository, + ) {} + + async save(userContribution: UserContributionEntity): Promise { + return this.repository.save(userContribution) + } + + async findByUserId(userId: string): Promise { + return this.repository.findOne({ where: { userId } }) + } + + async getLeaderboard( + startDate: Date, + endDate: Date, + contributionType?: ContributionTypeEnum, + pagination?: PaginationDto, + ) { + const queryBuilder = this.repository + .createQueryBuilder("userContribution") + .where("userContribution.lastContributionDate BETWEEN :startDate AND :endDate", { + startDate, + endDate, + }) + + // Filter by contribution type if provided + if (contributionType) { + switch (contributionType) { + case ContributionTypeEnum.SUBMISSION: + queryBuilder.andWhere("userContribution.submissionCount > 0") + queryBuilder.orderBy("userContribution.submissionCount", "DESC") + break + case ContributionTypeEnum.EDIT: + queryBuilder.andWhere("userContribution.editCount > 0") + queryBuilder.orderBy("userContribution.editCount", "DESC") + break + case ContributionTypeEnum.APPROVAL: + queryBuilder.andWhere("userContribution.approvalCount > 0") + queryBuilder.orderBy("userContribution.approvalCount", "DESC") + break + case ContributionTypeEnum.COMMENT: + queryBuilder.andWhere("userContribution.commentCount > 0") + queryBuilder.orderBy("userContribution.commentCount", "DESC") + break + default: + queryBuilder.orderBy("userContribution.totalPoints", "DESC") + break + } + } else { + queryBuilder.orderBy("userContribution.totalPoints", "DESC") + } + + // Add secondary sorting + queryBuilder.addOrderBy("userContribution.lastContributionDate", "DESC") + + // Add pagination + if (pagination) { + queryBuilder.skip((pagination.page - 1) * pagination.limit) + queryBuilder.take(pagination.limit) + } + + const [items, totalItems] = await queryBuilder.getManyAndCount() + + return { items, totalItems } + } + + async getUserRank(userId: string, timeRange: string): Promise { + // This is a simplified implementation + // In a real-world scenario, you would need to calculate this based on the time range + const result = await this.repository + .createQueryBuilder("userContribution") + .select("COUNT(*)", "rank") + .where( + "userContribution.totalPoints > (SELECT totalPoints FROM contribution_leaderboard_user_contributions WHERE userId = :userId)", + { userId }, + ) + .getRawOne() + + return Number.parseInt(result.rank, 10) + 1 // Add 1 because we're counting users with more points + } + + async countActiveUsersByDateRange(startDate: Date, endDate: Date): Promise { + return this.repository.count({ + where: { + lastContributionDate: MoreThan(startDate), + }, + }) + } + + async getTopContributors(startDate: Date, endDate: Date, limit: number) { + return this.repository.find({ + where: { + lastContributionDate: MoreThan(startDate), + }, + order: { + totalPoints: "DESC", + }, + take: limit, + }) + } + + async getAllUsersWithContributions(): Promise { + return this.repository.find({ + where: { + totalPoints: MoreThan(0), + }, + }) + } +} + diff --git a/backend/src/leadership/services/contribution-achievement.service.ts b/backend/src/leadership/services/contribution-achievement.service.ts new file mode 100644 index 0000000..1ade642 --- /dev/null +++ b/backend/src/leadership/services/contribution-achievement.service.ts @@ -0,0 +1,192 @@ +import { Injectable, Logger } from "@nestjs/common" +import type { AchievementRepository } from "../repositories/achievement.repository" +import type { UserAchievementRepository } from "../repositories/user-achievement.repository" +import type { UserContributionRepository } from "../repositories/user-contribution.repository" +import type { AchievementEntity } from "../entities/achievement.entity" +import { UserAchievementEntity } from "../entities/user-achievement.entity" +import type { ContributionEventEmitter } from "../events/contribution-event.emitter" +import type { ContributionLoggerService } from "./contribution-logger.service" + +@Injectable() +export class ContributionAchievementService { + private readonly logger = new Logger(ContributionAchievementService.name) + + constructor( + private readonly achievementRepository: AchievementRepository, + private readonly userAchievementRepository: UserAchievementRepository, + private readonly userContributionRepository: UserContributionRepository, + private readonly eventEmitter: ContributionEventEmitter, + private readonly loggerService: ContributionLoggerService, + ) { + this.initializeAchievements() + } + + private async initializeAchievements() { + // Create default achievements if they don't exist + const defaultAchievements = [ + { + id: "first-contribution", + name: "First Contribution", + description: "Made your first contribution", + icon: "🌱", + threshold: 1, + type: "total", + }, + { + id: "active-contributor", + name: "Active Contributor", + description: "Made 10 contributions", + icon: "🔥", + threshold: 10, + type: "total", + }, + { + id: "submission-master", + name: "Submission Master", + description: "Made 20 submissions", + icon: "📝", + threshold: 20, + type: "submission", + }, + { + id: "editor-extraordinaire", + name: "Editor Extraordinaire", + description: "Made 15 edits", + icon: "✏️", + threshold: 15, + type: "edit", + }, + { + id: "approval-authority", + name: "Approval Authority", + description: "Approved 10 contributions", + icon: "👍", + threshold: 10, + type: "approval", + }, + { + id: "top-contributor", + name: "Top Contributor", + description: "Reached the top 10 on the leaderboard", + icon: "🏆", + threshold: 10, + type: "rank", + }, + ] + + for (const achievement of defaultAchievements) { + const existingAchievement = await this.achievementRepository.findById(achievement.id) + + if (!existingAchievement) { + await this.achievementRepository.save({ + id: achievement.id, + name: achievement.name, + description: achievement.description, + icon: achievement.icon, + threshold: achievement.threshold, + type: achievement.type, + } as AchievementEntity) + + this.logger.log(`Created achievement: ${achievement.name}`) + } + } + } + + async checkAndAwardAchievements(userId: string): Promise { + this.logger.log(`Checking achievements for user: ${userId}`) + + // Get user contribution summary + const userContribution = await this.userContributionRepository.findByUserId(userId) + + if (!userContribution) { + this.logger.log(`No contributions found for user: ${userId}`) + return + } + + // Get all achievements + const achievements = await this.achievementRepository.find() + + // Get user's existing achievements + const userAchievements = await this.userAchievementRepository.findByUserId(userId) + const userAchievementIds = userAchievements.map((ua) => ua.achievementId) + + // Check each achievement + for (const achievement of achievements) { + // Skip if user already has this achievement + if (userAchievementIds.includes(achievement.id)) { + continue + } + + let shouldAward = false + + // Check if user meets the threshold for this achievement + switch (achievement.type) { + case "total": + shouldAward = this.getTotalContributions(userContribution) >= achievement.threshold + break + case "submission": + shouldAward = userContribution.submissionCount >= achievement.threshold + break + case "edit": + shouldAward = userContribution.editCount >= achievement.threshold + break + case "approval": + shouldAward = userContribution.approvalCount >= achievement.threshold + break + case "rank": + const userRank = await this.userContributionRepository.getUserRank(userId, "all-time") + shouldAward = userRank <= achievement.threshold + break + default: + break + } + + if (shouldAward) { + await this.awardAchievement(userId, achievement) + } + } + } + + private async awardAchievement(userId: string, achievement: AchievementEntity): Promise { + this.logger.log(`Awarding achievement ${achievement.name} to user ${userId}`) + + // Create user achievement + const userAchievement = new UserAchievementEntity() + userAchievement.userId = userId + userAchievement.achievementId = achievement.id + userAchievement.awardedAt = new Date() + + await this.userAchievementRepository.save(userAchievement) + + // Emit event + this.eventEmitter.emitAchievementAwarded(userId, achievement) + + // Log achievement + this.loggerService.logAchievementAwarded(userId, achievement.id, achievement.name) + } + + private getTotalContributions(userContribution): number { + return ( + userContribution.submissionCount + + userContribution.editCount + + userContribution.approvalCount + + userContribution.commentCount + ) + } + + async getUserAchievements(userId: string) { + const userAchievements = await this.userAchievementRepository.findByUserId(userId) + + // Enrich with achievement details + return Promise.all( + userAchievements.map(async (ua) => { + const achievement = await this.achievementRepository.findById(ua.achievementId) + return { + ...ua, + achievement, + } + }), + ) + } +} + diff --git a/backend/src/leadership/services/contribution-cache.service.ts b/backend/src/leadership/services/contribution-cache.service.ts new file mode 100644 index 0000000..d0642af --- /dev/null +++ b/backend/src/leadership/services/contribution-cache.service.ts @@ -0,0 +1,64 @@ +import { Injectable, Logger } from "@nestjs/common" +import { CACHE_MANAGER } from "@nestjs/cache-manager" +import { Inject } from "@nestjs/common" +import type { Cache } from "cache-manager" +import type { GetLeaderboardDto } from "../dto/get-leaderboard.dto" +import type { LeaderboardResponseDto } from "../dto/leaderboard-response.dto" + +@Injectable() +export class ContributionCacheService { + private readonly logger = new Logger(ContributionCacheService.name) + private readonly LEADERBOARD_CACHE_PREFIX = "contribution_leaderboard:" + private readonly CACHE_TTL = 60 * 60 * 1000; // 1 hour in milliseconds + + constructor( + @Inject(CACHE_MANAGER) private cacheManager: Cache, + ) {} + + generateLeaderboardCacheKey(dto: GetLeaderboardDto): string { + return `${this.LEADERBOARD_CACHE_PREFIX}${dto.timeRange}:${dto.contributionType || "all"}:${dto.pagination.page}:${dto.pagination.limit}` + } + + async getLeaderboardFromCache(cacheKey: string): Promise { + try { + const cachedData = await this.cacheManager.get(cacheKey) + + if (cachedData) { + this.logger.log(`Cache hit for key: ${cacheKey}`) + return cachedData + } + + this.logger.log(`Cache miss for key: ${cacheKey}`) + return null + } catch (error) { + this.logger.error(`Error getting data from cache: ${error.message}`, error.stack) + return null + } + } + + async cacheLeaderboardData(cacheKey: string, data: LeaderboardResponseDto): Promise { + try { + await this.cacheManager.set(cacheKey, data, this.CACHE_TTL) + this.logger.log(`Cached data with key: ${cacheKey}`) + } catch (error) { + this.logger.error(`Error caching data: ${error.message}`, error.stack) + } + } + + async invalidateLeaderboardCache(): Promise { + try { + // In a real implementation, you would need to use a cache that supports pattern deletion + // For simplicity, we're just logging that we would invalidate the cache + this.logger.log("Invalidating leaderboard cache") + + // Example implementation if using Redis: + // const keys = await this.redisClient.keys(`${this.LEADERBOARD_CACHE_PREFIX}*`); + // if (keys.length > 0) { + // await this.redisClient.del(...keys); + // } + } catch (error) { + this.logger.error(`Error invalidating cache: ${error.message}`, error.stack) + } + } +} + diff --git a/backend/src/leadership/services/contribution-leaderboard.service.ts b/backend/src/leadership/services/contribution-leaderboard.service.ts new file mode 100644 index 0000000..fadceee --- /dev/null +++ b/backend/src/leadership/services/contribution-leaderboard.service.ts @@ -0,0 +1,178 @@ +import { Injectable, Logger } from "@nestjs/common" +import type { GetLeaderboardDto } from "../dto/get-leaderboard.dto" +import type { CreateContributionDto } from "../dto/create-contribution.dto" +import type { LeaderboardResponseDto } from "../dto/leaderboard-response.dto" +import type { LeaderboardEntryDto } from "../dto/leaderboard-entry.dto" +import { TimeRangeEnum } from "../enums/time-range.enum" +import type { ContributionService } from "./contribution.service" +import type { UserContributionService } from "./user-contribution.service" +import type { ContributionCacheService } from "./contribution-cache.service" +import type { ContributionMetricsService } from "./contribution-metrics.service" +import type { ContributionValidationService } from "./contribution-validation.service" +import type { ContributionAchievementService } from "./contribution-achievement.service" +import type { PaginationDto } from "../dto/pagination.dto" + +@Injectable() +export class ContributionLeaderboardService { + private readonly logger = new Logger(ContributionLeaderboardService.name) + + constructor( + private readonly contributionService: ContributionService, + private readonly userContributionService: UserContributionService, + private readonly cacheService: ContributionCacheService, + private readonly metricsService: ContributionMetricsService, + private readonly validationService: ContributionValidationService, + private readonly achievementService: ContributionAchievementService, + ) {} + + async getLeaderboard(dto: GetLeaderboardDto): Promise { + this.logger.log(`Getting leaderboard with filters: ${JSON.stringify(dto)}`) + + // Try to get from cache first + const cacheKey = this.cacheService.generateLeaderboardCacheKey(dto) + const cachedData = await this.cacheService.getLeaderboardFromCache(cacheKey) + + if (cachedData) { + this.logger.log("Returning leaderboard data from cache") + return cachedData + } + + // Calculate date range based on timeRange + const { startDate, endDate } = this.calculateDateRange(dto.timeRange) + + // Get leaderboard data from repository + const { items, totalItems } = await this.userContributionService.getLeaderboard( + startDate, + endDate, + dto.contributionType, + dto.pagination, + ) + + // Transform to DTO + const leaderboardEntries = items.map((item, index) => { + const rank = (dto.pagination.page - 1) * dto.pagination.limit + index + 1 + + return { + rank, + userId: item.userId, + username: item.username || `User-${item.userId.substring(0, 8)}`, + avatarUrl: item.avatarUrl || `https://ui-avatars.com/api/?name=${item.username || "User"}&background=random`, + totalPoints: item.totalPoints, + contributionCounts: { + submissions: item.submissionCount || 0, + edits: item.editCount || 0, + approvals: item.approvalCount || 0, + total: (item.submissionCount || 0) + (item.editCount || 0) + (item.approvalCount || 0), + }, + achievements: item.achievements || [], + lastContributionDate: item.lastContributionDate, + } as LeaderboardEntryDto + }) + + const response: LeaderboardResponseDto = { + timeRange: dto.timeRange, + contributionType: dto.contributionType, + pagination: { + page: dto.pagination.page, + limit: dto.pagination.limit, + totalItems, + totalPages: Math.ceil(totalItems / dto.pagination.limit), + }, + entries: leaderboardEntries, + } + + // Store in cache + await this.cacheService.cacheLeaderboardData(cacheKey, response) + + // Track metrics + this.metricsService.trackLeaderboardRequest(dto) + + return response + } + + async recordContribution(dto: CreateContributionDto): Promise { + this.logger.log(`Recording contribution: ${JSON.stringify(dto)}`) + + // Validate contribution + await this.validationService.validateContribution(dto) + + // Record the contribution + const contribution = await this.contributionService.createContribution(dto) + + // Update user contribution aggregates + await this.userContributionService.updateUserContribution( + dto.userId, + dto.type, + dto.points || this.calculatePoints(dto.type), + ) + + // Check and award achievements + await this.achievementService.checkAndAwardAchievements(dto.userId) + + // Invalidate cache + await this.cacheService.invalidateLeaderboardCache() + + // Track metrics + this.metricsService.trackContributionCreated(dto) + + this.logger.log(`Contribution recorded successfully with ID: ${contribution.id}`) + } + + async getUserContributions(userId: string, timeRange: TimeRangeEnum, pagination: PaginationDto) { + this.logger.log(`Getting contributions for user: ${userId}, timeRange: ${timeRange}`) + + const { startDate, endDate } = this.calculateDateRange(timeRange) + + return this.contributionService.getUserContributions(userId, startDate, endDate, pagination) + } + + async getContributionTypes() { + return this.contributionService.getContributionTypes() + } + + async getStatistics(timeRange: TimeRangeEnum) { + const { startDate, endDate } = this.calculateDateRange(timeRange) + + return this.metricsService.getStatistics(startDate, endDate) + } + + private calculateDateRange(timeRange: TimeRangeEnum) { + const now = new Date() + let startDate: Date + const endDate = now + + switch (timeRange) { + case TimeRangeEnum.WEEKLY: + startDate = new Date(now) + startDate.setDate(now.getDate() - 7) + break + case TimeRangeEnum.MONTHLY: + startDate = new Date(now) + startDate.setMonth(now.getMonth() - 1) + break + case TimeRangeEnum.YEARLY: + startDate = new Date(now) + startDate.setFullYear(now.getFullYear() - 1) + break + case TimeRangeEnum.ALL_TIME: + default: + startDate = new Date(0) // Beginning of time + break + } + + return { startDate, endDate } + } + + private calculatePoints(contributionType: string): number { + // Default point values for different contribution types + const pointValues = { + submission: 10, + edit: 5, + approval: 3, + comment: 1, + } + + return pointValues[contributionType.toLowerCase()] || 1 + } +} + diff --git a/backend/src/leadership/services/contribution-logger.service.ts b/backend/src/leadership/services/contribution-logger.service.ts new file mode 100644 index 0000000..a6cde72 --- /dev/null +++ b/backend/src/leadership/services/contribution-logger.service.ts @@ -0,0 +1,39 @@ +import { Injectable, Logger } from "@nestjs/common" +import type { GetLeaderboardDto } from "../dto/get-leaderboard.dto" +import type { CreateContributionDto } from "../dto/create-contribution.dto" + +@Injectable() +export class ContributionLoggerService { + private readonly logger = new Logger(ContributionLoggerService.name) + + logLeaderboardRequest(dto: GetLeaderboardDto): void { + this.logger.log( + `Leaderboard requested with timeRange: ${dto.timeRange}, contributionType: ${dto.contributionType}, page: ${dto.pagination.page}, limit: ${dto.pagination.limit}`, + ) + } + + logContributionCreated(dto: CreateContributionDto): void { + this.logger.log(`Contribution created for user: ${dto.userId}, type: ${dto.type}, points: ${dto.points}`) + } + + logCacheHit(cacheKey: string): void { + this.logger.debug(`Cache hit for key: ${cacheKey}`) + } + + logCacheMiss(cacheKey: string): void { + this.logger.debug(`Cache miss for key: ${cacheKey}`) + } + + logError(message: string, error: Error): void { + this.logger.error(`${message}: ${error.message}`, error.stack) + } + + logAchievementAwarded(userId: string, achievementId: string, achievementName: string): void { + this.logger.log(`Achievement awarded to user ${userId}: ${achievementName} (${achievementId})`) + } + + logUserRankChanged(userId: string, oldRank: number, newRank: number): void { + this.logger.log(`User ${userId} rank changed from ${oldRank} to ${newRank}`) + } +} + diff --git a/backend/src/leadership/services/contribution-metrics.service.ts b/backend/src/leadership/services/contribution-metrics.service.ts new file mode 100644 index 0000000..468cb91 --- /dev/null +++ b/backend/src/leadership/services/contribution-metrics.service.ts @@ -0,0 +1,112 @@ +import { Injectable, Logger } from "@nestjs/common" +import type { ContributionRepository } from "../repositories/contribution.repository" +import type { UserContributionRepository } from "../repositories/user-contribution.repository" +import type { GetLeaderboardDto } from "../dto/get-leaderboard.dto" +import type { CreateContributionDto } from "../dto/create-contribution.dto" +import { ContributionTypeEnum } from "../enums/contribution-type.enum" + +@Injectable() +export class ContributionMetricsService { + private readonly logger = new Logger(ContributionMetricsService.name) + private metrics = { + leaderboardRequests: 0, + contributionsCreated: 0, + contributionsByType: { + [ContributionTypeEnum.SUBMISSION]: 0, + [ContributionTypeEnum.EDIT]: 0, + [ContributionTypeEnum.APPROVAL]: 0, + [ContributionTypeEnum.COMMENT]: 0, + }, + totalPoints: 0, + } + + constructor( + private readonly contributionRepository: ContributionRepository, + private readonly userContributionRepository: UserContributionRepository, + ) {} + + trackLeaderboardRequest(dto: GetLeaderboardDto): void { + this.metrics.leaderboardRequests++ + this.logger.debug(`Tracked leaderboard request. Total: ${this.metrics.leaderboardRequests}`) + } + + trackContributionCreated(dto: CreateContributionDto): void { + this.metrics.contributionsCreated++ + this.metrics.totalPoints += dto.points || 0 + + if (dto.type in this.metrics.contributionsByType) { + this.metrics.contributionsByType[dto.type]++ + } + + this.logger.debug(`Tracked contribution created. Total: ${this.metrics.contributionsCreated}`) + } + + async getStatistics(startDate: Date, endDate: Date) { + this.logger.log(`Getting statistics between ${startDate} and ${endDate}`) + + const totalContributions = await this.contributionRepository.countByDateRange(startDate, endDate) + const contributionsByType = await this.contributionRepository.countByTypeAndDateRange(startDate, endDate) + const totalUsers = await this.userContributionRepository.countActiveUsersByDateRange(startDate, endDate) + const topContributors = await this.userContributionRepository.getTopContributors(startDate, endDate, 5) + + return { + totalContributions, + contributionsByType, + totalUsers, + topContributors, + averageContributionsPerUser: totalUsers > 0 ? totalContributions / totalUsers : 0, + } + } + + async generateDailyMetrics() { + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + yesterday.setHours(0, 0, 0, 0) + + const today = new Date() + today.setHours(0, 0, 0, 0) + + return this.getStatistics(yesterday, today) + } + + async generateWeeklyMetrics() { + const lastWeek = new Date() + lastWeek.setDate(lastWeek.getDate() - 7) + lastWeek.setHours(0, 0, 0, 0) + + const today = new Date() + today.setHours(0, 0, 0, 0) + + return this.getStatistics(lastWeek, today) + } + + async generateMonthlyMetrics() { + const lastMonth = new Date() + lastMonth.setMonth(lastMonth.getMonth() - 1) + lastMonth.setHours(0, 0, 0, 0) + + const today = new Date() + today.setHours(0, 0, 0, 0) + + return this.getStatistics(lastMonth, today) + } + + getMetrics() { + return this.metrics + } + + resetMetrics() { + this.metrics = { + leaderboardRequests: 0, + contributionsCreated: 0, + contributionsByType: { + [ContributionTypeEnum.SUBMISSION]: 0, + [ContributionTypeEnum.EDIT]: 0, + [ContributionTypeEnum.APPROVAL]: 0, + [ContributionTypeEnum.COMMENT]: 0, + }, + totalPoints: 0, + } + } +} + diff --git a/backend/src/leadership/services/contribution-scheduler.service.ts b/backend/src/leadership/services/contribution-scheduler.service.ts new file mode 100644 index 0000000..e232559 --- /dev/null +++ b/backend/src/leadership/services/contribution-scheduler.service.ts @@ -0,0 +1,57 @@ +import { Injectable, Logger } from "@nestjs/common" +import { Cron, CronExpression } from "@nestjs/schedule" +import type { ContributionCacheService } from "./contribution-cache.service" +import type { ContributionMetricsService } from "./contribution-metrics.service" +import type { UserContributionService } from "./user-contribution.service" +import type { ContributionAchievementService } from "./contribution-achievement.service" + +@Injectable() +export class ContributionSchedulerService { + private readonly logger = new Logger(ContributionSchedulerService.name) + + constructor( + private readonly cacheService: ContributionCacheService, + private readonly metricsService: ContributionMetricsService, + private readonly userContributionService: UserContributionService, + private readonly achievementService: ContributionAchievementService, + ) {} + + @Cron(CronExpression.EVERY_HOUR) + async handleCacheInvalidation() { + this.logger.log("Running scheduled cache invalidation") + await this.cacheService.invalidateLeaderboardCache() + } + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async generateDailyMetrics() { + this.logger.log("Generating daily contribution metrics") + await this.metricsService.generateDailyMetrics() + } + + @Cron(CronExpression.EVERY_WEEK) + async generateWeeklyMetrics() { + this.logger.log("Generating weekly contribution metrics") + await this.metricsService.generateWeeklyMetrics() + } + + @Cron(CronExpression.EVERY_1ST_DAY_OF_MONTH_AT_MIDNIGHT) + async generateMonthlyMetrics() { + this.logger.log("Generating monthly contribution metrics") + await this.metricsService.generateMonthlyMetrics() + } + + @Cron(CronExpression.EVERY_DAY_AT_3AM) + async checkAndAwardAchievements() { + this.logger.log("Checking and awarding achievements") + + // Get all users with contributions + const users = await this.userContributionService.getAllUsersWithContributions() + + for (const user of users) { + await this.achievementService.checkAndAwardAchievements(user.userId) + } + + this.logger.log("Finished checking and awarding achievements") + } +} + diff --git a/backend/src/leadership/services/contribution-validation.service.ts b/backend/src/leadership/services/contribution-validation.service.ts new file mode 100644 index 0000000..5b8a58c --- /dev/null +++ b/backend/src/leadership/services/contribution-validation.service.ts @@ -0,0 +1,51 @@ +import { Injectable, Logger, BadRequestException, NotFoundException } from "@nestjs/common" +import type { CreateContributionDto } from "../dto/create-contribution.dto" +import type { ContributionTypeRepository } from "../repositories/contribution-type.repository" +import { ContributionTypeEnum } from "../enums/contribution-type.enum" + +@Injectable() +export class ContributionValidationService { + private readonly logger = new Logger(ContributionValidationService.name) + + constructor(private readonly contributionTypeRepository: ContributionTypeRepository) {} + + async validateContribution(dto: CreateContributionDto): Promise { + this.logger.log(`Validating contribution: ${JSON.stringify(dto)}`) + + // Check if user ID is provided + if (!dto.userId) { + throw new BadRequestException("User ID is required") + } + + // Check if contribution type is provided + if (!dto.type) { + throw new BadRequestException("Contribution type is required") + } + + // Check if contribution type exists + const contributionType = await this.contributionTypeRepository.findByName(dto.type) + + if (!contributionType && !this.isValidContributionType(dto.type)) { + throw new NotFoundException(`Contribution type ${dto.type} is not valid`) + } + + // Validate points if provided + if (dto.points !== undefined && dto.points < 0) { + throw new BadRequestException("Points cannot be negative") + } + + // Validate metadata if provided + if (dto.metadata && typeof dto.metadata !== "object") { + throw new BadRequestException("Metadata must be an object") + } + + this.logger.log("Contribution validation successful") + } + + private isValidContributionType(type: string): boolean { + // Check if the type is one of the predefined types + const validTypes = Object.values(ContributionTypeEnum) + return validTypes.includes(type as ContributionTypeEnum) + } +} + diff --git a/backend/src/leadership/services/contribution.service.ts b/backend/src/leadership/services/contribution.service.ts new file mode 100644 index 0000000..274ecb0 --- /dev/null +++ b/backend/src/leadership/services/contribution.service.ts @@ -0,0 +1,107 @@ +import { Injectable, Logger, NotFoundException } from "@nestjs/common" +import type { ContributionRepository } from "../repositories/contribution.repository" +import type { ContributionTypeRepository } from "../repositories/contribution-type.repository" +import type { CreateContributionDto } from "../dto/create-contribution.dto" +import { ContributionEntity } from "../entities/contribution.entity" +import type { PaginationDto } from "../dto/pagination.dto" +import { ContributionTypeEnum } from "../enums/contribution-type.enum" + +@Injectable() +export class ContributionService { + private readonly logger = new Logger(ContributionService.name) + + constructor( + private readonly contributionRepository: ContributionRepository, + private readonly contributionTypeRepository: ContributionTypeRepository, + ) {} + + async createContribution(dto: CreateContributionDto): Promise { + this.logger.log(`Creating contribution for user: ${dto.userId}, type: ${dto.type}`) + + // Get or create contribution type + const contributionType = await this.getOrCreateContributionType(dto.type) + + // Create contribution entity + const contribution = new ContributionEntity() + contribution.userId = dto.userId + contribution.contributionTypeId = contributionType.id + contribution.points = dto.points || this.getDefaultPointsForType(dto.type) + contribution.metadata = dto.metadata || {} + contribution.createdAt = new Date() + + // Save contribution + return this.contributionRepository.save(contribution) + } + + async getUserContributions(userId: string, startDate: Date, endDate: Date, pagination: PaginationDto) { + this.logger.log(`Getting contributions for user: ${userId} between ${startDate} and ${endDate}`) + + const { items, totalItems } = await this.contributionRepository.findUserContributions( + userId, + startDate, + endDate, + pagination, + ) + + // Enrich with contribution type details + const enrichedItems = await Promise.all( + items.map(async (item) => { + const contributionType = await this.contributionTypeRepository.findOne(item.contributionTypeId) + return { + ...item, + contributionType: contributionType ? contributionType.name : "Unknown", + } + }), + ) + + return { + items: enrichedItems, + pagination: { + page: pagination.page, + limit: pagination.limit, + totalItems, + totalPages: Math.ceil(totalItems / pagination.limit), + }, + } + } + + async getContributionTypes() { + return this.contributionTypeRepository.find() + } + + async getContributionsByType(type: ContributionTypeEnum, startDate: Date, endDate: Date, pagination: PaginationDto) { + const contributionType = await this.contributionTypeRepository.findByName(type) + + if (!contributionType) { + throw new NotFoundException(`Contribution type ${type} not found`) + } + + return this.contributionRepository.findByTypeAndDateRange(contributionType.id, startDate, endDate, pagination) + } + + private async getOrCreateContributionType(typeName: string) { + let contributionType = await this.contributionTypeRepository.findByName(typeName) + + if (!contributionType) { + this.logger.log(`Creating new contribution type: ${typeName}`) + contributionType = await this.contributionTypeRepository.createContributionType( + typeName, + this.getDefaultPointsForType(typeName), + ) + } + + return contributionType + } + + private getDefaultPointsForType(type: string): number { + const pointsMap = { + [ContributionTypeEnum.SUBMISSION]: 10, + [ContributionTypeEnum.EDIT]: 5, + [ContributionTypeEnum.APPROVAL]: 3, + [ContributionTypeEnum.COMMENT]: 1, + } + + return pointsMap[type] || 1 + } +} + diff --git a/backend/src/leadership/services/user-contribution.service.ts b/backend/src/leadership/services/user-contribution.service.ts new file mode 100644 index 0000000..cb15ca7 --- /dev/null +++ b/backend/src/leadership/services/user-contribution.service.ts @@ -0,0 +1,94 @@ +import { Injectable, Logger } from "@nestjs/common" +import type { UserContributionRepository } from "../repositories/user-contribution.repository" +import { UserContributionEntity } from "../entities/user-contribution.entity" +import { ContributionTypeEnum } from "../enums/contribution-type.enum" +import type { PaginationDto } from "../dto/pagination.dto" + +@Injectable() +export class UserContributionService { + private readonly logger = new Logger(UserContributionService.name) + + constructor(private readonly userContributionRepository: UserContributionRepository) {} + + async updateUserContribution( + userId: string, + contributionType: string, + points: number, + ): Promise { + this.logger.log(`Updating user contribution for user: ${userId}, type: ${contributionType}, points: ${points}`) + + // Get existing user contribution or create new one + let userContribution = await this.userContributionRepository.findByUserId(userId) + + if (!userContribution) { + userContribution = new UserContributionEntity() + userContribution.userId = userId + userContribution.totalPoints = 0 + userContribution.submissionCount = 0 + userContribution.editCount = 0 + userContribution.approvalCount = 0 + userContribution.commentCount = 0 + } + + // Update counts based on contribution type + switch (contributionType) { + case ContributionTypeEnum.SUBMISSION: + userContribution.submissionCount += 1 + break + case ContributionTypeEnum.EDIT: + userContribution.editCount += 1 + break + case ContributionTypeEnum.APPROVAL: + userContribution.approvalCount += 1 + break + case ContributionTypeEnum.COMMENT: + userContribution.commentCount += 1 + break + default: + // Handle other contribution types + break + } + + // Update total points and last contribution date + userContribution.totalPoints += points + userContribution.lastContributionDate = new Date() + + // Save updated user contribution + return this.userContributionRepository.save(userContribution) + } + + async getLeaderboard( + startDate: Date, + endDate: Date, + contributionType?: ContributionTypeEnum, + pagination?: PaginationDto, + ) { + this.logger.log(`Getting leaderboard between ${startDate} and ${endDate}, type: ${contributionType}`) + + return this.userContributionRepository.getLeaderboard(startDate, endDate, contributionType, pagination) + } + + async getUserRank(userId: string, timeRange: string): Promise { + return this.userContributionRepository.getUserRank(userId, timeRange) + } + + async getUserContributionSummary(userId: string): Promise { + const userContribution = await this.userContributionRepository.findByUserId(userId) + + if (!userContribution) { + // Return default empty summary + return { + userId, + totalPoints: 0, + submissionCount: 0, + editCount: 0, + approvalCount: 0, + commentCount: 0, + lastContributionDate: null, + } as UserContributionEntity + } + + return userContribution + } +} +