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
3 changes: 2 additions & 1 deletion BackendAcademy/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ When integrating a frontend built with **shadcn/ui**, backend endpoints should p
]
}
```
```

---

Expand Down Expand Up @@ -265,4 +266,4 @@ Base score is 50. Final score is clamped to [0, 100]. A `confidence` of `0.7` is
- **Dynamic hint generation** — when no hint is stored for a `challengeId`, fall through to the AI provider to generate one on-demand using the task description as context.
- **Per-user hint gating** — track how many hints a learner has consumed per challenge and reduce XP payout accordingly.
- **Database persistence** — migrate `hints` and `chatHistory` maps to PostgreSQL via the Supabase client.
- **Streaming responses** — switch the chat endpoint to Server-Sent Events for real-time token streaming.
- **Streaming responses** — switch the chat endpoint to Server-Sent Events for real-time token streaming.
2 changes: 2 additions & 0 deletions BackendAcademy/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { MonitoringModule } from './monitoring/monitoring.module';
import { SearchModule } from './search/search.module';
import { PaymentsModule } from './payments/payments.module';
import { SessionsModule } from './sessions/sessions.module';
import { ReportsModule } from './reports/reports.module';

@Module({
imports: [
Expand Down Expand Up @@ -67,6 +68,7 @@ import { SessionsModule } from './sessions/sessions.module';
SearchModule,
PaymentsModule,
SessionsModule,
ReportsModule,
],
controllers: [AppController],
providers: [
Expand Down
21 changes: 21 additions & 0 deletions BackendAcademy/src/reports/dto/daily-summary-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsISO8601, IsOptional } from 'class-validator';

export class DailySummaryQueryDto {
@IsOptional()
@IsISO8601()
startDate?: string;

@IsOptional()
@IsISO8601()
endDate?: string;

@IsOptional()
@Transform(({ value }) => {
if (value === undefined) return true;
if (typeof value === 'boolean') return value;
return value === 'true';
})
@IsBoolean()
includeEmptyDays: boolean = true;
}
21 changes: 21 additions & 0 deletions BackendAcademy/src/reports/reports.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { DailySummaryQueryDto } from './dto/daily-summary-query.dto';
import { DailySummaryReport, ReportsService } from './reports.service';

@Controller('reports')
export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}

@Get('daily-summaries/:userId')
async getDailySummaries(
@Param('userId') userId: string,
@Query() query: DailySummaryQueryDto,
): Promise<DailySummaryReport> {
return this.reportsService.getDailySummaryReport(
userId,
query.startDate,
query.endDate,
query.includeEmptyDays,
);
}
}
12 changes: 12 additions & 0 deletions BackendAcademy/src/reports/reports.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AnalyticsModule } from '../analytics/analytics.module';
import { RewardsModule } from '../rewards/rewards.module';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';

@Module({
imports: [AnalyticsModule, RewardsModule],
controllers: [ReportsController],
providers: [ReportsService],
})
export class ReportsModule {}
154 changes: 154 additions & 0 deletions BackendAcademy/src/reports/reports.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { AnalyticsService } from '../analytics/analytics.service';
import { RewardsService } from '../rewards/rewards.service';
import { ReportsService } from './reports.service';

describe('ReportsService', () => {
let service: ReportsService;

const analyticsService = {
getEventsByUserId: jest.fn(),
} as unknown as AnalyticsService;

const rewardsService = {
getUserProgression: jest.fn(),
} as unknown as RewardsService;

beforeEach(() => {
service = new ReportsService(analyticsService, rewardsService);
jest.clearAllMocks();
});

it('builds daily summaries with empty days and progress metrics', async () => {
(analyticsService.getEventsByUserId as jest.Mock).mockResolvedValue([
{
id: '1',
userId: 'learner-1',
eventType: 'course_completed',
timestamp: new Date('2026-07-01T09:00:00.000Z'),
},
{
id: '2',
userId: 'learner-1',
eventType: 'lesson_viewed',
timestamp: new Date('2026-07-01T11:00:00.000Z'),
},
{
id: '3',
userId: 'learner-1',
eventType: 'challenge_started',
timestamp: new Date('2026-07-03T15:30:00.000Z'),
},
]);
(rewardsService.getUserProgression as jest.Mock).mockReturnValue({
xp: 450,
level: 4,
xpToNextLevel: 50,
currentLevelThreshold: 400,
nextLevelThreshold: 500,
streak: {
currentStreak: 2,
lastActivityDate: '2026-07-03T15:30:00.000Z',
},
});

const report = await service.getDailySummaryReport(
'learner-1',
'2026-07-01T00:00:00.000Z',
'2026-07-03T23:59:59.999Z',
true,
);

expect(report.summaries).toHaveLength(3);
expect(report.summaries[0]).toMatchObject({
date: '2026-07-01',
totalEvents: 2,
uniqueEventTypes: 2,
});
expect(report.summaries[1]).toMatchObject({
date: '2026-07-02',
totalEvents: 0,
});
expect(report.progress).toMatchObject({
totalDays: 3,
activeDays: 2,
inactiveDays: 1,
activityRate: 66.67,
totalEvents: 3,
uniqueEventTypes: 3,
currentActiveStreak: 1,
longestActiveStreak: 1,
});
expect(report.progress.rewards.level).toBe(4);
});

it('omits empty days when requested', async () => {
(analyticsService.getEventsByUserId as jest.Mock).mockResolvedValue([
{
id: '1',
userId: 'learner-1',
eventType: 'course_completed',
timestamp: new Date('2026-07-01T09:00:00.000Z'),
},
]);
(rewardsService.getUserProgression as jest.Mock).mockReturnValue({
xp: 100,
level: 2,
xpToNextLevel: 20,
currentLevelThreshold: 80,
nextLevelThreshold: 120,
streak: {
currentStreak: 1,
lastActivityDate: '2026-07-01T09:00:00.000Z',
},
});

const report = await service.getDailySummaryReport(
'learner-1',
'2026-07-01T00:00:00.000Z',
'2026-07-03T23:59:59.999Z',
false,
);

expect(report.summaries).toHaveLength(1);
expect(report.summaries[0].date).toBe('2026-07-01');
expect(report.progress.inactiveDays).toBe(2);
});

it('falls back to zeroed rewards progress when progression is missing', async () => {
(analyticsService.getEventsByUserId as jest.Mock).mockResolvedValue([]);
(rewardsService.getUserProgression as jest.Mock).mockImplementation(() => {
throw new NotFoundException('missing');
});

const report = await service.getDailySummaryReport(
'learner-1',
'2026-07-01T00:00:00.000Z',
'2026-07-01T23:59:59.999Z',
true,
);

expect(report.progress.rewards).toEqual({
xp: 0,
level: 1,
xpToNextLevel: 0,
currentLevelThreshold: 0,
nextLevelThreshold: null,
currentStreak: 0,
lastActivityDate: null,
});
});

it('throws for invalid date ranges', async () => {
(analyticsService.getEventsByUserId as jest.Mock).mockResolvedValue([]);

await expect(
service.getDailySummaryReport(
'learner-1',
'2026-07-03T00:00:00.000Z',
'2026-07-01T23:59:59.999Z',
true,
),
).rejects.toThrow(BadRequestException);
});
});
Loading
Loading