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
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddDifficultyAndCategoryToIQQuestions20250601204322
implements MigrationInterface
{
export class AddDifficultyAndCategoryToIQQuestions20250601204322 implements MigrationInterface {
name = 'AddDifficultyAndCategoryToIQQuestions20250601204322';

public async up(queryRunner: QueryRunner): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateDailyStreaksTable20250601204323
implements MigrationInterface
{
export class CreateDailyStreaksTable20250601204323 implements MigrationInterface {
name = 'CreateDailyStreaksTable20250601204323';

public async up(queryRunner: QueryRunner): Promise<void> {
Expand Down
10 changes: 0 additions & 10 deletions backend/src/progress/providers/get-category-stats.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { UserProgress } from '../entities/progress.entity';

interface CategoryStatsRaw {
categoryId: string;
totalAttempts: string;
correctAnswers: string;
}

interface CategoryNameRaw {
category_name: string;
}

@Injectable()
export class GetCategoryStatsProvider {
constructor(
Expand Down
7 changes: 0 additions & 7 deletions backend/src/progress/providers/get-overall-stats.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,6 @@ import { FindOptionsWhere, Repository } from 'typeorm';
import { UserProgress } from '../entities/progress.entity';
import { OverallStatsDto } from '../dtos/overall-stats.dto';

interface OverallStatsRaw {
totalAttempts: string;
totalCorrect: string;
totalPointsEarned: string;
totalTimeSpent: string;
}

@Injectable()
export class GetOverallStatsProvider {
constructor(
Expand Down
10 changes: 0 additions & 10 deletions backend/src/progress/providers/progress-calculation.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { UserProgress } from '../entities/progress.entity';
import { SubmitAnswerDto } from '../dtos/submit-answer.dto';
import { XpLevelService } from '../../users/providers/xp-level.service';
import { User } from '../../users/user.entity';
import { Streak } from '../../streak/entities/streak.entity';
import { DailyQuest } from '../../quests/entities/daily-quest.entity';
import { getPointsByDifficulty } from '../../puzzles/enums/puzzle-difficulty.enum';

Expand All @@ -21,13 +20,6 @@ export interface ProgressCalculationResult {
validation: AnswerValidationResult;
}

interface ProgressStatsRaw {
totalAttempts: string;
correctAttempts: string;
totalPoints: string;
averageTimeSpent: string;
}

@Injectable()
export class ProgressCalculationProvider {
constructor(
Expand All @@ -38,8 +30,6 @@ export class ProgressCalculationProvider {
private readonly xpLevelService: XpLevelService,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(Streak)
private readonly streakRepository: Repository<Streak>,
@InjectRepository(DailyQuest)
private readonly dailyQuestRepository: Repository<DailyQuest>,
) {}
Expand Down
8 changes: 2 additions & 6 deletions backend/src/puzzles/dtos/create-puzzle.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ import {
} from '../enums/puzzle-difficulty.enum';

@ValidatorConstraint({ name: 'correctAnswerInOptions', async: false })
export class CorrectAnswerInOptionsConstraint
implements ValidatorConstraintInterface
{
export class CorrectAnswerInOptionsConstraint implements ValidatorConstraintInterface {
validate(correctAnswer: string, args: ValidationArguments) {
const object = args.object as CreatePuzzleDto;
return object.options?.includes(correctAnswer) || false;
Expand All @@ -34,9 +32,7 @@ export class CorrectAnswerInOptionsConstraint
}

@ValidatorConstraint({ name: 'optionsMinimumLength', async: false })
export class OptionsMinimumLengthConstraint
implements ValidatorConstraintInterface
{
export class OptionsMinimumLengthConstraint implements ValidatorConstraintInterface {
validate(options: string[]) {
return options?.length >= 2;
}
Expand Down
12 changes: 9 additions & 3 deletions backend/src/quests/controllers/daily-quest.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export class DailyQuestController {
})
async getTodaysDailyQuest(
@ActiveUser('sub') userId: string,
userTimeZone: string,
): Promise<DailyQuestResponseDto> {
console.log('REQUEST_USER_KEY:', request['user']);
console.log('Full request keys:', Object.keys(request));
Expand All @@ -50,7 +51,7 @@ export class DailyQuestController {
if (!userId) {
throw new UnauthorizedException('User ID not found in token');
}
return this.dailyQuestService.getTodaysDailyQuest(userId);
return this.dailyQuestService.getTodaysDailyQuest(userId, userTimeZone);
}

@Get('status')
Expand All @@ -72,11 +73,15 @@ export class DailyQuestController {
})
async getTodaysDailyQuestStatus(
@ActiveUser('sub') userId: string,
userTimeZone: string,
): Promise<DailyQuestStatusDto> {
if (!userId) {
throw new UnauthorizedException('User ID not found in token');
}
return this.dailyQuestService.getTodaysDailyQuestStatus(userId);
return this.dailyQuestService.getTodaysDailyQuestStatus(
userId,
userTimeZone,
);
}

@Post('complete')
Expand Down Expand Up @@ -106,10 +111,11 @@ export class DailyQuestController {
})
async completeDailyQuest(
@ActiveUser('sub') userId: string,
userTimeZone: string,
): Promise<CompleteDailyQuestResponseDto> {
if (!userId) {
throw new UnauthorizedException('User ID not found in token');
}
return this.dailyQuestService.completeDailyQuest(userId);
return this.dailyQuestService.completeDailyQuest(userId, userTimeZone);
}
}
27 changes: 11 additions & 16 deletions backend/src/quests/providers/complete-daily-quest.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,35 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { DataSource } from 'typeorm';
import { DailyQuest } from '../entities/daily-quest.entity';
import { User } from '../../users/user.entity';
import { UpdateStreakProvider } from '../../streak/providers/update-streak.provider';
import { CompleteDailyQuestResponseDto } from '../dtos/complete-daily-quest.dto';
import { getDateString } from '../../shared/utils/date.util';

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

constructor(
@InjectRepository(DailyQuest)
private readonly dailyQuestRepository: Repository<DailyQuest>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly updateStreakProvider: UpdateStreakProvider,
private readonly dataSource: DataSource,
) {}

async execute(userId: string): Promise<CompleteDailyQuestResponseDto> {
const todayDate = this.getTodayDateString();
async execute(
userId: string,
userTimeZone: string,
): Promise<CompleteDailyQuestResponseDto> {
const todayDate = getDateString(userTimeZone, 0);
this.logger.log(
`Attempting to complete daily quest for user ${userId} on ${todayDate}`,
);

// Parse userId to number for streak operations
const userIdNumber = parseInt(userId, 10);
if (isNaN(userIdNumber)) {
throw new BadRequestException('Invalid user ID format');
}
const userIdNumber = userId;

// Use transaction to ensure atomicity
const transactionResult = await this.dataSource.transaction(
Expand Down Expand Up @@ -130,6 +128,7 @@ export class CompleteDailyQuestProvider {
// 7. Update streak after transaction commits
const streak = await this.updateStreakProvider.updateStreak(
transactionResult.userId!,
userTimeZone,
);

this.logger.log(
Expand All @@ -145,14 +144,10 @@ export class CompleteDailyQuestProvider {
streakInfo: {
currentStreak: streak.currentStreak,
longestStreak: streak.longestStreak,
lastActivityDate: streak.lastActivityDate || this.getTodayDateString(),
lastActivityDate:
streak.lastActivityDate || getDateString(userTimeZone, 0),
},
completedAt: transactionResult.completedAt,
};
}

private getTodayDateString(): string {
const now = new Date();
return now.toISOString().split('T')[0];
}
}
13 changes: 9 additions & 4 deletions backend/src/quests/providers/daily-quest.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,21 @@ export class DailyQuestService {
private readonly completeDailyQuestProvider: CompleteDailyQuestProvider,
) {}

async getTodaysDailyQuest(userId: string): Promise<DailyQuestResponseDto> {
return this.getTodaysDailyQuestProvider.execute(userId);
async getTodaysDailyQuest(
userId: string,
userTimezone: string,
): Promise<DailyQuestResponseDto> {
return this.getTodaysDailyQuestProvider.execute(userId, userTimezone);
}

/**
* Returns the status of today's Daily Quest (read-only, lightweight)
*/
async getTodaysDailyQuestStatus(
userId: string,
userTimeZone: string,
): Promise<DailyQuestStatusDto> {
return this.getTodaysDailyQuestStatusProvider.execute(userId);
return this.getTodaysDailyQuestStatusProvider.execute(userId, userTimeZone);
}

/**
Expand All @@ -33,7 +37,8 @@ export class DailyQuestService {
*/
async completeDailyQuest(
userId: string,
userTimeZone: string,
): Promise<CompleteDailyQuestResponseDto> {
return this.completeDailyQuestProvider.execute(userId);
return this.completeDailyQuestProvider.execute(userId, userTimeZone);
}
}
14 changes: 7 additions & 7 deletions backend/src/quests/providers/getTodaysDailyQuest.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { User } from '../../users/user.entity';
import { DailyQuestResponseDto } from '../dtos/daily-quest-response.dto';
import { PuzzleResponseDto } from '../dtos/puzzle-response.dto';
import { PuzzleDifficulty } from '../../puzzles/enums/puzzle-difficulty.enum';
import { getDateString } from '../../shared/utils/date.util';

@Injectable()
export class GetTodaysDailyQuestProvider {
Expand All @@ -30,8 +31,12 @@ export class GetTodaysDailyQuestProvider {
private readonly userRepository: Repository<User>,
) {}

async execute(userId: string): Promise<DailyQuestResponseDto> {
const todayDate = this.getTodayDateString();
async execute(
userId: string,
userTimezone: string,
): Promise<DailyQuestResponseDto> {
const todayDate = getDateString(userTimezone, 0);

this.logger.log(`Fetching daily quest for user ${userId} on ${todayDate}`);

let dailyQuest = await this.findExistingQuest(userId, todayDate);
Expand All @@ -46,11 +51,6 @@ export class GetTodaysDailyQuestProvider {
return this.buildQuestResponse(dailyQuest);
}

private getTodayDateString(): string {
const now = new Date();
return now.toISOString().split('T')[0];
}

private async findExistingQuest(
userId: string,
questDate: string,
Expand Down
16 changes: 6 additions & 10 deletions backend/src/quests/providers/getTodaysDailyQuestStatus.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Repository } from 'typeorm';
import { DailyQuest } from '../entities/daily-quest.entity';
import { DailyQuestStatusDto } from '../dtos/daily-quest-status.dto';
import { GetTodaysDailyQuestProvider } from './getTodaysDailyQuest.provider';
import { getDateString } from '../../shared/utils/date.util';

/**
* Provider for fetching the status of today's Daily Quest.
Expand All @@ -29,8 +30,11 @@ export class GetTodaysDailyQuestStatusProvider {
* @param userId - The user's ID
* @returns DailyQuestStatusDto with totalQuestions, completedQuestions, isCompleted
*/
async execute(userId: string): Promise<DailyQuestStatusDto> {
const todayDate = this.getTodayDateString();
async execute(
userId: string,
userTimeZone: string,
): Promise<DailyQuestStatusDto> {
const todayDate = getDateString(userTimeZone, 0);
this.logger.log(
`Fetching daily quest status for user ${userId} on ${todayDate}`,
);
Expand Down Expand Up @@ -66,14 +70,6 @@ export class GetTodaysDailyQuestStatusProvider {
return this.buildStatusResponse(dailyQuest);
}

/**
* Returns today's date as YYYY-MM-DD string (timezone-safe)
*/
private getTodayDateString(): string {
const now = new Date();
return now.toISOString().split('T')[0];
}

/**
* Converts DailyQuest entity to DailyQuestStatusDto
*/
Expand Down
11 changes: 11 additions & 0 deletions backend/src/shared/utils/date.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Returns a YYYY-MM-DD string in the given timezone.
* @param timeZone IANA timezone string (e.g. "America/New_York")
* @param offsetDays Number of days to offset (0 = today, -1 = yesterday)
*/
export function getDateString(timeZone: string, offsetDays = 0): string {
const now = new Date();
now.setDate(now.getUTCDate() + offsetDays);

return now.toLocaleDateString('en-CA', { timeZone });
}
10 changes: 4 additions & 6 deletions backend/src/streak/providers/streaks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,20 @@ import { Streak } from '../entities/streak.entity';

@Injectable()
export class StreaksService {
constructor(
private readonly updateStreakProvider: UpdateStreakProvider,
) {}
constructor(private readonly updateStreakProvider: UpdateStreakProvider) {}

/**
* Get user's current streak
*/
async getStreak(userId: number): Promise<Streak | null> {
async getStreak(userId: string): Promise<Streak | null> {
return this.updateStreakProvider.getStreak(userId);
}

/**
* Update streak after daily quest completion
* Handles increment, reset, and longest streak tracking
*/
async updateStreak(userId: number): Promise<Streak> {
return this.updateStreakProvider.updateStreak(userId);
async updateStreak(userId: string, userTimezone: string): Promise<Streak> {
return this.updateStreakProvider.updateStreak(userId, userTimezone);
}
}
Loading