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 @@ -2,6 +2,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';

import { AuthModule } from './auth/auth.module';
import appConfig from './config/app.config';
Expand Down Expand Up @@ -29,6 +30,7 @@ import { GamificationModule } from './gamification/gamification.module';
envFilePath: ['.env'],
load: [appConfig, databaseConfig],
}),
EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
Expand Down
43 changes: 43 additions & 0 deletions src/database/migrations/20250601204323-CreateDailyStreaksTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

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

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "daily_streaks" (
"id" SERIAL NOT NULL,
"user_id" integer NOT NULL,
"last_active_date" date NOT NULL,
"streak_count" integer NOT NULL DEFAULT 0,
"longest_streak" integer NOT NULL DEFAULT 0,
"last_milestone_reached" integer,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_daily_streaks_id" PRIMARY KEY ("id"),
CONSTRAINT "UQ_daily_streaks_user_id" UNIQUE ("user_id")
);

ALTER TABLE "daily_streaks"
ADD CONSTRAINT "FK_daily_streaks_user_id"
FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;

CREATE INDEX "IDX_daily_streaks_user_id" ON "daily_streaks" ("user_id");
CREATE INDEX "IDX_daily_streaks_streak_count" ON "daily_streaks" ("streak_count" DESC);
CREATE INDEX "IDX_daily_streaks_longest_streak" ON "daily_streaks" ("longest_streak" DESC);
CREATE INDEX "IDX_daily_streaks_last_active_date" ON "daily_streaks" ("last_active_date" DESC);
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX "IDX_daily_streaks_last_active_date";
DROP INDEX "IDX_daily_streaks_longest_streak";
DROP INDEX "IDX_daily_streaks_streak_count";
DROP INDEX "IDX_daily_streaks_user_id";

ALTER TABLE "daily_streaks" DROP CONSTRAINT "FK_daily_streaks_user_id";
DROP TABLE "daily_streaks";
`);
}
}
232 changes: 232 additions & 0 deletions src/gamification/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
# Daily Streak System

A comprehensive daily streak system that rewards users for solving at least one puzzle per day. This system tracks consecutive days of participation, resets if a day is missed, and provides bonus XP or tokens for milestone streaks.

## Features

- **Daily Streak Tracking**: Tracks consecutive days of puzzle solving
- **Milestone Rewards**: Awards bonus XP and tokens for reaching streak milestones
- **Event-Driven Architecture**: Uses NestJS event emitter for loose coupling
- **Leaderboard**: Shows top streak holders
- **Statistics**: Provides admin statistics for streak analytics
- **Automatic Integration**: Works with both puzzle and IQ assessment systems

## Architecture

### Components

1. **DailyStreak Entity** (`entities/daily-streak.entity.ts`)
- Stores user streak data
- Tracks current streak, longest streak, and milestone progress

2. **DailyStreakService** (`providers/daily-streak.service.ts`)
- Core business logic for streak management
- Handles streak updates, milestone checking, and leaderboard queries

3. **StreakController** (`controllers/streak.controller.ts`)
- REST API endpoints for streak operations
- Protected with JWT authentication

4. **StreakListener** (`listeners/streak.listener.ts`)
- Event listener for puzzle and IQ question submissions
- Automatically updates streaks when users solve puzzles correctly

5. **Constants** (`constants/streak.constants.ts`)
- Configuration for milestones and rewards
- Event names and system configuration

## API Endpoints

### Get Current Streak
```
GET /streak
Authorization: Bearer <jwt_token>
```

**Response:**
```json
{
"streakCount": 5,
"longestStreak": 10,
"lastActiveDate": "2024-01-15T00:00:00.000Z",
"hasSolvedToday": true,
"nextMilestone": 7,
"daysUntilNextMilestone": 2
}
```

### Get Streak Leaderboard
```
GET /streak/leaderboard?page=1&limit=10
Authorization: Bearer <jwt_token>
```

**Response:**
```json
{
"entries": [
{
"userId": 1,
"username": "user1",
"streakCount": 15,
"longestStreak": 20,
"lastActiveDate": "2024-01-15T00:00:00.000Z"
}
],
"total": 1,
"page": 1,
"limit": 10
}
```

### Get Streak Statistics (Admin)
```
GET /streak/stats
Authorization: Bearer <jwt_token>
```

**Response:**
```json
{
"totalUsers": 100,
"activeUsers": 50,
"averageStreak": 5,
"topStreak": 30
}
```

## Milestone System

The system awards bonus rewards at the following milestones:

| Streak Days | XP Reward | Token Reward | Description |
|-------------|-----------|--------------|-------------|
| 3 | 50 | 5 | 3-Day Streak |
| 7 | 150 | 15 | 7-Day Streak |
| 14 | 300 | 30 | 14-Day Streak |
| 30 | 600 | 60 | 30-Day Streak |
| 60 | 1200 | 120 | 60-Day Streak |
| 100 | 2000 | 200 | 100-Day Streak |

## Event System

The streak system uses events for loose coupling:

### Events Emitted
- `puzzle.submitted`: When a puzzle is submitted
- `iq.question.answered`: When an IQ question is answered
- `streak.puzzle.solved`: When a streak is updated
- `streak.milestone.reached`: When a milestone is reached

### Event Listeners
- `StreakListener`: Listens for puzzle and IQ question events
- `GamificationService`: Listens for milestone events to award rewards

## Database Schema

### daily_streaks Table
```sql
CREATE TABLE daily_streaks (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL UNIQUE,
last_active_date DATE NOT NULL,
streak_count INTEGER NOT NULL DEFAULT 0,
longest_streak INTEGER NOT NULL DEFAULT 0,
last_milestone_reached INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
);
```

## Integration

### Puzzle System Integration
The streak system automatically integrates with the puzzle submission system:

1. User submits puzzle solution
2. `PuzzleService` emits `puzzle.submitted` event
3. `StreakListener` handles the event
4. If solution is correct, streak is updated
5. If milestone is reached, bonus rewards are awarded

### IQ Assessment Integration
Similar integration with IQ assessment system:

1. User answers IQ question
2. `IQAssessmentService` emits `iq.question.answered` event
3. `StreakListener` handles the event
4. If answer is correct, streak is updated

## Testing

The system includes comprehensive unit tests:

- `daily-streak.service.spec.ts`: Tests for core streak logic
- `streak.listener.spec.ts`: Tests for event handling
- `streak.controller.spec.ts`: Tests for API endpoints
- `puzzle.service.spec.ts`: Tests for puzzle integration

Run tests with:
```bash
npm run test src/gamification
```

## Configuration

### Environment Variables
No additional environment variables required. The system uses existing database and JWT configuration.

### Customization
To modify milestone rewards, update `src/gamification/constants/streak.constants.ts`:

```typescript
export const STREAK_MILESTONES = {
3: { xp: 50, tokens: 5, description: '3-Day Streak' },
// Add or modify milestones here
};
```

## Usage Examples

### Frontend Integration
```typescript
// Get current user streak
const streak = await api.get('/streak');

// Display streak information
console.log(`Current streak: ${streak.streakCount} days`);
console.log(`Longest streak: ${streak.longestStreak} days`);
console.log(`Next milestone: ${streak.nextMilestone} days`);

// Get leaderboard
const leaderboard = await api.get('/streak/leaderboard?page=1&limit=10');
```

### Backend Integration
```typescript
// Inject DailyStreakService
constructor(private readonly streakService: DailyStreakService) {}

// Update streak manually (if needed)
const streak = await this.streakService.updateStreak(userId);

// Get streak statistics
const stats = await this.streakService.getStreakStats();
```

## Best Practices

1. **Event-Driven**: Use events for loose coupling between systems
2. **Error Handling**: Streak updates should not break puzzle submission flow
3. **Idempotency**: Users can only update streak once per day
4. **Performance**: Use database indexes for leaderboard queries
5. **Testing**: Comprehensive test coverage for all business logic

## Future Enhancements

- **Streak Multipliers**: Bonus multipliers for longer streaks
- **Streak Challenges**: Special challenges for maintaining streaks
- **Streak Analytics**: More detailed analytics and insights
- **Streak Notifications**: Push notifications for streak reminders
- **Streak Sharing**: Social features for sharing streak achievements
19 changes: 19 additions & 0 deletions src/gamification/constants/streak.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const STREAK_MILESTONES = {
3: { xp: 50, tokens: 5, description: '3-Day Streak' },
7: { xp: 150, tokens: 15, description: '7-Day Streak' },
14: { xp: 300, tokens: 30, description: '14-Day Streak' },
30: { xp: 600, tokens: 60, description: '30-Day Streak' },
60: { xp: 1200, tokens: 120, description: '60-Day Streak' },
100: { xp: 2000, tokens: 200, description: '100-Day Streak' },
} as const;

export const STREAK_EVENTS = {
PUZZLE_SOLVED: 'streak.puzzle.solved',
MILESTONE_REACHED: 'streak.milestone.reached',
} as const;

export const STREAK_CONFIG = {
MAX_STREAK_MILESTONE: 100,
BASE_XP_PER_PUZZLE: 10,
BASE_TOKENS_PER_PUZZLE: 1,
} as const;
Loading
Loading