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
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { QuestsModule } from './quests/quests.module';
import { IntegrationsModule } from './integrations/integrations.module';
import { BlockchainTransactionModule } from './blockchain-transaction/blockchain-transaction.module';
import { PrivacyModule } from './privacy/privacy.module';
import { DailyChallengesModule } from './daily-challenges/daily-challenges.module';
import { EnergyModule } from './energy/energy.module';
import { SkillRatingModule } from './skill-rating/skill-rating.module';
import { WalletAuthModule } from './auth/wallet-auth.module';
Expand Down Expand Up @@ -129,6 +130,7 @@ import { WalletAuthModule } from './auth/wallet-auth.module';
IntegrationsModule,
BlockchainTransactionModule,
PrivacyModule,
DailyChallengesModule,
SkillRatingModule,
WalletAuthModule,
],
Expand Down
53 changes: 53 additions & 0 deletions src/daily-challenges/controllers/daily-challenges.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {
Controller,
Get,
Post,
Body,
Param,
UseGuards,
Query,
Req,
} from '@nestjs/common';
import { DailyChallengesService } from '../services/daily-challenges.service';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { Request } from 'express';

// Ensure the Request type has user property injected by passport-jwt
interface AuthenticatedRequest extends Request {
user: { id: string; email: string; role: string };
}

@Controller('daily-challenges')
@UseGuards(JwtAuthGuard)
export class DailyChallengesController {
constructor(
private readonly dailyChallengesService: DailyChallengesService,
) {}

@Get('today')
async getTodayChallenge(@Req() req: AuthenticatedRequest) {
const userId = req.user.id;
return this.dailyChallengesService.getActiveChallenge(userId);
}

@Post(':id/complete')
async completeChallenge(
@Param('id') challengeId: string,
@Body() body: { score: number; timeSpent: number },
@Req() req: AuthenticatedRequest,
) {
return this.dailyChallengesService.completeChallenge(
req.user.id,
challengeId,
body,
);
}

@Get('history')
async getHistory(
@Req() req: AuthenticatedRequest,
@Query('limit') limit: number,
) {
return this.dailyChallengesService.getHistory(req.user.id, limit || 30);
}
}
30 changes: 30 additions & 0 deletions src/daily-challenges/daily-challenges.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';

import { DailyChallenge } from './entities/daily-challenge.entity';
import { DailyChallengeCompletion } from './entities/daily-challenge-completion.entity';
import { DailyChallengesService } from './services/daily-challenges.service';
import { ChallengeRotationCron } from './services/challenge-rotation.cron';
import { DailyChallengesController } from './controllers/daily-challenges.controller';

import { Puzzle } from '../puzzles/entities/puzzle.entity';
import { UserStreak } from '../users/entities/user-streak.entity';
import { User } from '../users/entities/user.entity';

@Module({
imports: [
TypeOrmModule.forFeature([
DailyChallenge,
DailyChallengeCompletion,
Puzzle,
UserStreak,
User,
]),
ScheduleModule.forRoot(),
],
controllers: [DailyChallengesController],
providers: [DailyChallengesService, ChallengeRotationCron],
exports: [DailyChallengesService],
})
export class DailyChallengesModule {}
48 changes: 48 additions & 0 deletions src/daily-challenges/entities/daily-challenge-completion.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { DailyChallenge } from './daily-challenge.entity';

@Entity('daily_challenge_completions')
@Index(['userId', 'dailyChallengeId'], { unique: true })
export class DailyChallengeCompletion {
@PrimaryGeneratedColumn('uuid')
id: string;

@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;

@Column()
@Index()
userId: string;

@ManyToOne(() => DailyChallenge, (challenge) => challenge.completions, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'dailyChallengeId' })
dailyChallenge: DailyChallenge;

@Column()
@Index()
dailyChallengeId: string;

@Column({ type: 'int', default: 0 })
score: number;

@Column({ type: 'int', default: 0 })
timeSpent: number; // in seconds

@Column({ type: 'int', default: 0 })
streakBonusAwarded: number;

@CreateDateColumn({ type: 'timestamp with time zone' })
completedAt: Date;
}
54 changes: 54 additions & 0 deletions src/daily-challenges/entities/daily-challenge.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { Puzzle } from '../../puzzles/entities/puzzle.entity';
import { DailyChallengeCompletion } from './daily-challenge-completion.entity';

@Entity('daily_challenges')
@Index(['challengeDate'], { unique: true })
@Index(['isActive'])
export class DailyChallenge {
@PrimaryGeneratedColumn('uuid')
id: string;

@ManyToOne(() => Puzzle, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'puzzleId' })
puzzle: Puzzle;

@Column()
@Index()
puzzleId: string;

@Column({ type: 'date' })
@Index()
challengeDate: Date; // A fixed date (UTC midnight)

@Column({ type: 'float', default: 1.0 })
difficultyModifier: number;

@Column({ type: 'int', default: 100 })
baseRewardPoints: number;

@Column({ default: true })
isActive: boolean;

@OneToMany(
() => DailyChallengeCompletion,
(completion) => completion.dailyChallenge,
)
completions: DailyChallengeCompletion[];

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
130 changes: 130 additions & 0 deletions src/daily-challenges/services/challenge-rotation.cron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { DailyChallenge } from '../entities/daily-challenge.entity';
import { Puzzle } from '../../puzzles/entities/puzzle.entity';

@Injectable()
export class ChallengeRotationCron {
private readonly logger = new Logger(ChallengeRotationCron.name);

constructor(
@InjectRepository(DailyChallenge)
private readonly dailyChallengeRepo: Repository<DailyChallenge>,
@InjectRepository(Puzzle)
private readonly puzzleRepo: Repository<Puzzle>,
) {}

/**
* Helper to get start of current UTC Day
*/
private getStartOfUTCDay(date: Date = new Date()): Date {
const d = new Date(date);
d.setUTCHours(0, 0, 0, 0);
return d;
}

@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT, { timeZone: 'UTC' })
async rotateDailyChallenge() {
this.logger.log('Starting daily challenge rotation cron...');

const today = this.getStartOfUTCDay();

// Deactivate previous active challenges
await this.dailyChallengeRepo.update(
{ challengeDate: today, isActive: true },
{ isActive: false },
); // (Should not exist yet for today, but edge case handling)

// Check if we already have one created
const existing = await this.dailyChallengeRepo.findOne({
where: { challengeDate: today },
});
if (existing) {
if (!existing.isActive) {
existing.isActive = true;
await this.dailyChallengeRepo.save(existing);
}
return;
}

// Determine target difficulty for today. (e.g. rotating: easy -> medium -> hard -> expert)
const dayOfWeek = new Date().getUTCDay(); // 0 is Sunday, 6 is Saturday
let targetDifficulty: 'easy' | 'medium' | 'hard' | 'expert' = 'medium';

if (dayOfWeek === 1 || dayOfWeek === 2)
targetDifficulty = 'easy'; // Mon, Tue
else if (dayOfWeek === 3 || dayOfWeek === 4)
targetDifficulty = 'medium'; // Wed, Thu
else if (dayOfWeek === 5)
targetDifficulty = 'hard'; // Fri
else targetDifficulty = 'expert'; // Sat, Sun

// Fetch random puzzle of this difficulty that has not been a daily challenge recently
const recentChallenges = await this.dailyChallengeRepo.find({
order: { challengeDate: 'DESC' },
take: 30, // Don't reuse puzzles from last 30 days
select: ['puzzleId'],
});

const excludedPuzzleIds = recentChallenges.map((rc) => rc.puzzleId);

const query = this.puzzleRepo
.createQueryBuilder('puzzle')
.where('puzzle.isActive = :isActive', { isActive: true })
.andWhere('puzzle.difficulty = :difficulty', {
difficulty: targetDifficulty,
});

if (excludedPuzzleIds.length > 0) {
query.andWhere('puzzle.id NOT IN (:...excludedIds)', {
excludedIds: excludedPuzzleIds,
});
}

// Pick a random one
query.orderBy('RANDOM()').limit(1);
const selectedPuzzle = await query.getOne();

if (!selectedPuzzle) {
this.logger.error(
'Failed to find an active puzzle for the daily challenge! Falling back to any puzzle.',
);
// Fallback: Just grab any active puzzle
const fallbackPuzzle = await this.puzzleRepo
.createQueryBuilder('puzzle')
.where('puzzle.isActive = :isActive', { isActive: true })
.orderBy('RANDOM()')
.limit(1)
.getOne();

if (fallbackPuzzle) {
await this.createChallengeForPuzzle(fallbackPuzzle, today);
} else {
this.logger.error(
'No puzzles found in the database. Cannot create daily challenge.',
);
}
return;
}

await this.createChallengeForPuzzle(selectedPuzzle, today);
}

private async createChallengeForPuzzle(puzzle: Puzzle, date: Date) {
const dailyChallenge = this.dailyChallengeRepo.create({
puzzleId: puzzle.id,
challengeDate: date,
isActive: true,
difficultyModifier: 1.0, // Base modifier
baseRewardPoints: puzzle.basePoints * 2, // Double points for daily challenges!
});

await this.dailyChallengeRepo.save(dailyChallenge);
this.logger.log(
`Created new daily challenge for ${date.toISOString()}: Puzzle ID ${puzzle.id}`,
);
}
}
Loading