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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions BackendAcademy/src/leaderboard/leaderboard.service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'crypto';
import { GetLeaderboardDto } from './dto/get-leaderboard.dto';
import { LeaderboardEntry, LeaderboardResponse } from './interfaces/leaderboard.interface';
import { v4 as uuidv4 } from 'uuid';

/** BA-121: stable deterministic ID derived from username — survives restarts */
function stableId(username: string): string {
return createHash('sha256').update(`leaderboard:${username}`).digest('hex').slice(0, 36);
}

@Injectable()
export class LeaderboardService {
// Sample leaderboard data - in a real implementation, this would come from a database
// BA-121: IDs are now deterministic (SHA-256 of username) so snapshots are
// cacheable and userId-based rank queries work across process restarts.
private sampleUsers: Omit<LeaderboardEntry, 'rank'>[] = [
{
userId: uuidv4(),
userId: stableId('rustmaster'),
username: 'rustmaster',
avatarUrl: 'https://example.com/avatars/rustmaster.png',
score: 15420,
Expand All @@ -17,7 +23,7 @@ export class LeaderboardService {
streak: 45,
},
{
userId: uuidv4(),
userId: stableId('codewarrior'),
username: 'codewarrior',
avatarUrl: 'https://example.com/avatars/codewarrior.png',
score: 14890,
Expand All @@ -26,7 +32,7 @@ export class LeaderboardService {
streak: 32,
},
{
userId: uuidv4(),
userId: stableId('memorieslock'),
username: 'memorieslock',
avatarUrl: 'https://example.com/avatars/memorieslock.png',
score: 14250,
Expand All @@ -35,7 +41,7 @@ export class LeaderboardService {
streak: 28,
},
{
userId: uuidv4(),
userId: stableId('rustacean'),
username: 'rustacean',
avatarUrl: 'https://example.com/avatars/rustacean.png',
score: 13780,
Expand All @@ -44,7 +50,7 @@ export class LeaderboardService {
streak: 21,
},
{
userId: uuidv4(),
userId: stableId('systemshade'),
username: 'systemshade',
avatarUrl: 'https://example.com/avatars/systemshade.png',
score: 13150,
Expand All @@ -53,7 +59,7 @@ export class LeaderboardService {
streak: 18,
},
{
userId: uuidv4(),
userId: stableId('codelover'),
username: 'codelover',
avatarUrl: 'https://example.com/avatars/codelover.png',
score: 12890,
Expand All @@ -62,7 +68,7 @@ export class LeaderboardService {
streak: 15,
},
{
userId: uuidv4(),
userId: stableId('learningdev'),
username: 'learningdev',
avatarUrl: 'https://example.com/avatars/learningdev.png',
score: 11560,
Expand All @@ -71,7 +77,7 @@ export class LeaderboardService {
streak: 12,
},
{
userId: uuidv4(),
userId: stableId('newbiecoder'),
username: 'newbiecoder',
avatarUrl: 'https://example.com/avatars/newbiecoder.png',
score: 9870,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
import { createHash } from 'crypto';
import { LeaderboardEntry } from '../interfaces/leaderboard.interface';
import { ILeaderboardRepository } from './leaderboard.repository.interface';
import { v4 as uuidv4 } from 'uuid';

/**
* BA-121: Derive stable, deterministic user IDs from usernames so leaderboard
* snapshots are cacheable and rank lookups by userId work across restarts.
* Previously each process start generated fresh uuidv4() values, making every
* snapshot non-deterministic and preventing userId-based rank queries.
*/
function stableId(username: string): string {
return createHash('sha256').update(`leaderboard:${username}`).digest('hex').slice(0, 36);
}

/**
* In-memory implementation of the leaderboard repository.
* Stores leaderboard entries in process-local arrays.
* Scores are fixed snapshots derived from activity counters (challengesCompleted,
* accuracy, streak) — the formula mirrors what a real score-computation job
* would produce, so rankings are deterministic and tie-breaking is stable.
*/
export class InMemoryLeaderboardRepository implements ILeaderboardRepository {
private sampleUsers: Omit<LeaderboardEntry, 'rank'>[] = [
{
userId: uuidv4(),
userId: stableId('rustmaster'),
username: 'rustmaster',
avatarUrl: 'https://example.com/avatars/rustmaster.png',
score: 15420,
Expand All @@ -18,7 +31,7 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository {
streak: 45,
},
{
userId: uuidv4(),
userId: stableId('codewarrior'),
username: 'codewarrior',
avatarUrl: 'https://example.com/avatars/codewarrior.png',
score: 14890,
Expand All @@ -27,7 +40,7 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository {
streak: 32,
},
{
userId: uuidv4(),
userId: stableId('memorieslock'),
username: 'memorieslock',
avatarUrl: 'https://example.com/avatars/memorieslock.png',
score: 14250,
Expand All @@ -36,7 +49,7 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository {
streak: 28,
},
{
userId: uuidv4(),
userId: stableId('rustacean'),
username: 'rustacean',
avatarUrl: 'https://example.com/avatars/rustacean.png',
score: 13780,
Expand All @@ -45,7 +58,7 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository {
streak: 21,
},
{
userId: uuidv4(),
userId: stableId('systemshade'),
username: 'systemshade',
avatarUrl: 'https://example.com/avatars/systemshade.png',
score: 13150,
Expand All @@ -54,7 +67,7 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository {
streak: 18,
},
{
userId: uuidv4(),
userId: stableId('codelover'),
username: 'codelover',
avatarUrl: 'https://example.com/avatars/codelover.png',
score: 12890,
Expand All @@ -63,7 +76,7 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository {
streak: 15,
},
{
userId: uuidv4(),
userId: stableId('learningdev'),
username: 'learningdev',
avatarUrl: 'https://example.com/avatars/learningdev.png',
score: 11560,
Expand All @@ -72,7 +85,7 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository {
streak: 12,
},
{
userId: uuidv4(),
userId: stableId('newbiecoder'),
username: 'newbiecoder',
avatarUrl: 'https://example.com/avatars/newbiecoder.png',
score: 9870,
Expand All @@ -94,15 +107,11 @@ export class InMemoryLeaderboardRepository implements ILeaderboardRepository {
}): Omit<LeaderboardEntry, 'rank'>[] {
let candidates = [...this.sampleUsers];

// Course scope filtering (same logic as original service)
if (filters.courseId) {
const bucket = filters.courseId.length % 2;
candidates = candidates.filter((_, idx) => idx % 2 === bucket);
}

// Note: timeRange, category, and difficulty filters are stubs
// These would be implemented when real data is available

return candidates;
}

Expand Down
6 changes: 5 additions & 1 deletion BackendAcademy/src/social/dto/get-social-feed.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { IsInt, IsOptional, IsPositive, Min, IsString, MaxLength } from 'class-validator';
import { IsInt, IsOptional, IsPositive, Min, Max, IsString, MaxLength } from 'class-validator';
import { Type } from 'class-transformer';

/** BA-119: Maximum feed page size to prevent resource exhaustion. */
const MAX_FEED_PAGE_SIZE = 100;

export class GetSocialFeedDto {
@IsOptional()
@IsString()
Expand All @@ -12,6 +15,7 @@ export class GetSocialFeedDto {
@IsInt()
@IsPositive()
@Min(1)
@Max(MAX_FEED_PAGE_SIZE)
limit?: number = 10;

@IsOptional()
Expand Down
11 changes: 11 additions & 0 deletions BackendAcademy/src/social/social.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,21 @@ export class SocialService {
private readonly hashtags = new Map<string, Hashtag>();
private idCounter = 1;

/** BA-119: Maximum number of hashtags allowed per post to prevent spam. */
private static readonly MAX_HASHTAGS_PER_POST = 10;

createPost(userId: string, dto: CreateSocialPostDto): SocialPost {
const normalizedUserId = this.normalizeUserId(userId);
const normalizedContent = this.normalizeContent(dto.content);

// BA-119: Reject posts that exceed the hashtag limit.
const hashtagCount = (normalizedContent.match(/#[a-zA-Z0-9_]+/g) ?? []).length;
if (hashtagCount > SocialService.MAX_HASHTAGS_PER_POST) {
throw new BadRequestException(
`Post may contain at most ${SocialService.MAX_HASHTAGS_PER_POST} hashtags, found ${hashtagCount}.`,
);
}

const post: SocialPost = {
id: this.generateId(),
userId: normalizedUserId,
Expand Down
14 changes: 14 additions & 0 deletions app/backend/src/module-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ export type AppImport =
| Promise<DynamicModule>
| ForwardReference<unknown>;

/**
* Typed loader that encapsulates dynamic module composition behind a
* well-typed interface (Issue #336).
*
* AppModule should call `EnvironmentModuleLoader.getModules(config)` instead
* of calling `getDynamicModules` directly, so the composition logic is in one
* place and the env-config contract is explicit.
*/
export class EnvironmentModuleLoader {
static getModules(config: EnvConfig): AppImport[] {
return getDynamicModules(config);
}
}

/**
* Returns the list of dynamic modules to be loaded based on the application configuration.
* This factory ensures that module loading is deterministic and based on typed config.
Expand Down
Loading