Skip to content

Commit 89d3642

Browse files
committed
Add quest analytics rollup job
- Create QuestAnalytics entity for daily quest aggregation - Add QuestAnalyticsRollupJob scheduled at 00:30 UTC - Aggregate daily quest assignment/completion counts - Create migration for quest_analytics table - Register job and entities in analytics module
1 parent f489eab commit 89d3642

4 files changed

Lines changed: 142 additions & 0 deletions

File tree

backend/src/analytics/analytics.module.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
33
import { AnalyticsEvent } from './entities/analytics-event.entity';
44
import { RetentionCohort } from './entities/retention-cohort.entity';
55
import { DailyActiveUser } from './entities/daily-active-user.entity';
6+
import { QuestAnalytics } from './entities/quest-analytics.entity';
7+
import { DailyQuest } from '../quests/entities/daily-quest.entity';
68
import { UsersAnalyticsListener } from './listeners/users-analytics.listener';
79
import { BlockchainAnalyticsListener } from './listeners/blockchain-analytics.listener';
810
import { AnalyticsController } from './controllers/analytics.controller';
@@ -13,13 +15,17 @@ import { GetRetentionCurveProvider } from './providers/get-retention-curve.provi
1315
import { GetChurnRiskProvider } from './providers/get-churn-risk.provider';
1416
import { PuzzleAnalyticsProvider } from './providers/puzzle-analytics.provider';
1517
import { ExportCsvProvider } from './providers/export-csv.provider';
18+
import { DailyActiveUsersRollupJob } from './jobs/daily-active-users-rollup.job';
19+
import { QuestAnalyticsRollupJob } from './jobs/quest-analytics-rollup.job';
1620

1721
@Module({
1822
imports: [
1923
TypeOrmModule.forFeature([
2024
AnalyticsEvent,
2125
RetentionCohort,
2226
DailyActiveUser,
27+
QuestAnalytics,
28+
DailyQuest,
2329
]),
2430
],
2531
controllers: [AnalyticsController],
@@ -33,6 +39,8 @@ import { ExportCsvProvider } from './providers/export-csv.provider';
3339
GetChurnRiskProvider,
3440
PuzzleAnalyticsProvider,
3541
ExportCsvProvider,
42+
DailyActiveUsersRollupJob,
43+
QuestAnalyticsRollupJob,
3644
],
3745
exports: [
3846
AnalyticsService,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {
2+
Column,
3+
CreateDateColumn,
4+
Entity,
5+
Index,
6+
PrimaryGeneratedColumn,
7+
} from 'typeorm';
8+
9+
/**
10+
* Aggregated daily quest analytics.
11+
* One row per day containing assignment and completion counts.
12+
* Materialized by QuestAnalyticsRollupJob to avoid joining raw quest tables.
13+
*/
14+
@Entity('quest_analytics')
15+
@Index(['date'], { unique: true })
16+
export class QuestAnalytics {
17+
@PrimaryGeneratedColumn()
18+
id: number;
19+
20+
@Column('date')
21+
date: string;
22+
23+
@Column('int', { default: 0 })
24+
assignedCount: number;
25+
26+
@Column('int', { default: 0 })
27+
completedCount: number;
28+
29+
@CreateDateColumn({ type: 'timestamptz' })
30+
createdAt: Date;
31+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { Cron } from '@nestjs/schedule';
3+
import { InjectRepository } from '@nestjs/typeorm';
4+
import { Repository } from 'typeorm';
5+
import { DailyQuest } from '../../quests/entities/daily-quest.entity';
6+
import { QuestAnalytics } from '../entities/quest-analytics.entity';
7+
8+
/**
9+
* Nightly rollup that materializes `QuestAnalytics` rows from raw
10+
* `DailyQuest` data for the previous day, so quest completion rates can be
11+
* read cheaply without joining quest tables on every request.
12+
*
13+
* Scheduled to run at 00:30 UTC to ensure it runs after quest reset time.
14+
*/
15+
@Injectable()
16+
export class QuestAnalyticsRollupJob {
17+
private readonly logger = new Logger(QuestAnalyticsRollupJob.name);
18+
19+
constructor(
20+
@InjectRepository(DailyQuest)
21+
private readonly dailyQuestRepository: Repository<DailyQuest>,
22+
@InjectRepository(QuestAnalytics)
23+
private readonly questAnalyticsRepository: Repository<QuestAnalytics>,
24+
) {}
25+
26+
@Cron('30 0 * * *') // Runs at 00:30 UTC daily (after quest reset)
27+
async handleCron(): Promise<void> {
28+
const yesterday = new Date();
29+
yesterday.setDate(yesterday.getDate() - 1);
30+
await this.rollupForDate(yesterday);
31+
}
32+
33+
/**
34+
* Recomputes the `QuestAnalytics` row for `targetDate`. Safe to re-run
35+
* for the same day: existing row for that date is replaced rather than
36+
* appended to.
37+
*/
38+
async rollupForDate(
39+
targetDate: Date,
40+
): Promise<{ date: string; assignedCount: number; completedCount: number }> {
41+
const dateStr = targetDate.toISOString().split('T')[0];
42+
43+
// Count total quests assigned for this date
44+
const assignedCount = await this.dailyQuestRepository.count({
45+
where: { questDate: dateStr },
46+
});
47+
48+
// Count quests completed for this date
49+
const completedCount = await this.dailyQuestRepository.count({
50+
where: { questDate: dateStr, isCompleted: true },
51+
});
52+
53+
// Delete existing row for this date (if any)
54+
await this.questAnalyticsRepository.delete({ date: dateStr });
55+
56+
// Insert new aggregated row
57+
const questAnalytics = this.questAnalyticsRepository.create({
58+
date: dateStr,
59+
assignedCount,
60+
completedCount,
61+
});
62+
await this.questAnalyticsRepository.save(questAnalytics);
63+
64+
this.logger.log(
65+
`Quest analytics rollup for ${dateStr}: ${assignedCount} assigned, ${completedCount} completed`,
66+
);
67+
68+
return { date: dateStr, assignedCount, completedCount };
69+
}
70+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddQuestAnalyticsTable20260727000000 implements MigrationInterface {
4+
name = 'AddQuestAnalyticsTable20260727000000';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`
8+
-- Create quest_analytics table
9+
CREATE TABLE IF NOT EXISTS "quest_analytics" (
10+
"id" SERIAL NOT NULL,
11+
"date" date NOT NULL,
12+
"assignedCount" integer NOT NULL DEFAULT 0,
13+
"completedCount" integer NOT NULL DEFAULT 0,
14+
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
15+
CONSTRAINT "PK_quest_analytics_id" PRIMARY KEY ("id"),
16+
CONSTRAINT "UQ_quest_analytics_date" UNIQUE ("date")
17+
);
18+
19+
-- Create indexes for quest_analytics
20+
CREATE INDEX "IDX_quest_analytics_date" ON "quest_analytics" ("date");
21+
`);
22+
}
23+
24+
public async down(queryRunner: QueryRunner): Promise<void> {
25+
await queryRunner.query(`
26+
-- Drop indexes
27+
DROP INDEX IF EXISTS "IDX_quest_analytics_date";
28+
29+
-- Drop table
30+
DROP TABLE IF EXISTS "quest_analytics";
31+
`);
32+
}
33+
}

0 commit comments

Comments
 (0)