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
10 changes: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
# MySQL example
DB_TYPE=mysql
# Database Config (Matches mulearnbackend)
DATABASE_ENGINE=django.db.backends.mysql
DATABASE_HOST=localhost
DATABASE_PORT=3306
DATABASE_USER=root
DATABASE_PASSWORD=your_password_here
DATABASE_NAME=scheduler
DATABASE_NAME=mulearn
DB_SYNC=false
DB_LOGGING=false

# Discord & Security Config
SCHEDULER_SERVICE_API_KEY=your_secure_api_key_here
DISCORD_WEBHOOK_LINK=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN

# SQLite fallback example
# DB_TYPE=sqlite
# DB_FILE=database.sqlite
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Prod CI

on:
push:
branches: [ "main" ]
branches: [ "production" ]
workflow_dispatch:

jobs:
Expand Down
23 changes: 13 additions & 10 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SchedulerModule } from './scheduler/scheduler.module';
import { APP_GUARD } from '@nestjs/core';
import { ApiKeyGuard } from './guards/api-key.guard';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ApiKeyGuard } from './common/guards/api-key.guard';
import { JobsModule } from './modules/jobs/jobs.module';
import { GritMeterModule } from './modules/grit-meter/grit-meter.module';

@Module({
imports: [
Expand All @@ -14,11 +15,12 @@ import { ApiKeyGuard } from './guards/api-key.guard';
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const dbType = config.get<string>('DB_TYPE');
const dbEngine = config.get<string>('DATABASE_ENGINE') || config.get<string>('DB_TYPE') || 'mysql';
const isMysql = dbEngine.includes('mysql') || dbEngine.includes('mariadb');
const entities = [__dirname + '/**/*.entity{.ts,.js}'];

if (dbType !== 'mysql') {
throw new Error('Only MySQL database is supported. Please check DB_TYPE in .env');
if (!isMysql) {
throw new Error(`Unsupported database engine "${dbEngine}". Please check DATABASE_ENGINE / DB_TYPE in .env`);
}

return {
Expand All @@ -27,14 +29,15 @@ import { ApiKeyGuard } from './guards/api-key.guard';
port: parseInt(config.get<string>('DATABASE_PORT', '3306'), 10),
username: config.get<string>('DATABASE_USER', 'root'),
password: config.get<string>('DATABASE_PASSWORD', ''),
database: config.get<string>('DATABASE_NAME', 'scheduler'),
database: config.get<string>('DATABASE_NAME', 'mulearn'),
entities,
synchronize: config.get<string>('DB_SYNC', 'false') === 'true',
logging: config.get<string>('DB_LOGGING', 'false') === 'true',
};
},
}),
SchedulerModule,
JobsModule,
GritMeterModule,
],
controllers: [AppController],
providers: [
Expand All @@ -45,4 +48,4 @@ import { ApiKeyGuard } from './guards/api-key.guard';
},
],
})
export class AppModule { }
export class AppModule {}
38 changes: 38 additions & 0 deletions src/common/guards/api-key.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';

@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}

canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const authHeader = request.headers.authorization;

if (!authHeader) {
throw new UnauthorizedException('Missing Authorization header');
}

const [type, token] = authHeader.split(' ');

if (type !== 'Bearer' || !token) {
throw new UnauthorizedException('Invalid Authorization header format');
}

const apiKey =
this.configService.get<string>('SCHEDULER_SERVICE_API_KEY') ||
this.configService.get<string>('API_KEY');

if (token !== apiKey) {
throw new UnauthorizedException('Invalid API Key');
}

return true;
}
}
36 changes: 0 additions & 36 deletions src/guards/api-key.guard.ts

This file was deleted.

13 changes: 13 additions & 0 deletions src/modules/grit-meter/grit-meter.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Controller, Post } from '@nestjs/common';
import { GritMeterService } from './grit-meter.service';

@Controller('grit-meter')
export class GritMeterController {
constructor(private readonly gritMeterSvc: GritMeterService) {}

@Post('process')
async processGritMeter() {
const result = await this.gritMeterSvc.processDailyGritMeter();
return { success: true, ...result };
}
Comment on lines +8 to +12
}
10 changes: 10 additions & 0 deletions src/modules/grit-meter/grit-meter.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { GritMeterService } from './grit-meter.service';
import { GritMeterController } from './grit-meter.controller';

@Module({
controllers: [GritMeterController],
providers: [GritMeterService],
exports: [GritMeterService],
})
export class GritMeterModule {}
179 changes: 179 additions & 0 deletions src/modules/grit-meter/grit-meter.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import * as cron from 'node-cron';

@Injectable()
export class GritMeterService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(GritMeterService.name);
private cronJob?: cron.ScheduledTask;

constructor(
private readonly dataSource: DataSource,
private readonly configService: ConfigService,
) {}

onModuleInit() {
this.logger.log('Initializing GritMeterService cron job (00:00 UTC daily)');
this.cronJob = cron.schedule(
'0 0 * * *',
() => {
this.processDailyGritMeter().catch((err) =>
this.logger.error('Error processing daily grit meter', err),
);
},
{ timezone: 'UTC' },
);
}

onModuleDestroy() {
if (this.cronJob) {
this.cronJob.stop();
}
}

async processDailyGritMeter(): Promise<{ processed: number; levelDowns: number }> {
this.logger.log('Starting Daily Grit Meter / HP Processing');

const isEnabled = await this.checkFeatureFlag();
if (!isEnabled) {
Comment on lines +36 to +40
this.logger.warn('Grit Meter processing skipped: Feature flag "grit_meter_enabled" is OFF');
return { processed: 0, levelDowns: 0 };
}

const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();

let processedCount = 0;
let levelDownCount = 0;

try {
Comment on lines +45 to +51
const userLinks: Array<{
id: string;
user_id: string;
level_id: string;
grit: number;
level_order: number;
}> = await queryRunner.query(`
SELECT
ull.id,
ull.user_id,
ull.level_id,
COALESCE(ull.grit, 50) AS grit,
l.level_order
FROM user_lvl_link ull
JOIN level l ON ull.level_id = l.id
`);

this.logger.log(`Found ${userLinks.length} user level records to process`);

const webhookUrl = this.configService.get<string>('DISCORD_WEBHOOK_LINK');

for (const link of userLinks) {
const activityResult: Array<{ activity_count: number }> = await queryRunner.query(
`
SELECT COUNT(*) AS activity_count
FROM karma_activity_log
Comment on lines +73 to +77
WHERE user_id = ?
AND created_at >= DATE_SUB(CURDATE(), INTERVAL 1 DAY)
AND created_at < CURDATE()
`,
[link.user_id],
);

const hasActivity = (activityResult[0]?.activity_count ?? 0) > 0;
let newGrit = hasActivity ? Math.min(100, link.grit + 1) : link.grit - 1;

if (newGrit <= 0) {
if (link.level_order >= 5) {
const targetLevelOrder = link.level_order - 1;
const targetLevel: Array<{ id: string }> = await queryRunner.query(
`SELECT id FROM level WHERE level_order = ? LIMIT 1`,
[targetLevelOrder],
);

if (targetLevel.length > 0) {
const lowerLevelId = targetLevel[0].id;
newGrit = 100;

await queryRunner.query(
`
UPDATE user_lvl_link
SET level_id = ?,
grit = ?,
last_level_down_at = NOW(),
updated_at = NOW()
WHERE id = ?
`,
[lowerLevelId, newGrit, link.id],
);

levelDownCount++;
this.logger.log(
`User ${link.user_id} leveled down: Level ${link.level_order} -> Level ${targetLevelOrder}. Grit reset to 100%.`,
);

if (webhookUrl) {
await this.sendDiscordWebhook(webhookUrl, link.user_id);
}
} else {
await queryRunner.query(
`UPDATE user_lvl_link SET grit = 0, updated_at = NOW() WHERE id = ?`,
[link.id],
);
}
} else {
await queryRunner.query(
`UPDATE user_lvl_link SET grit = 0, updated_at = NOW() WHERE id = ?`,
[link.id],
);
}
} else {
await queryRunner.query(
`UPDATE user_lvl_link SET grit = ?, updated_at = NOW() WHERE id = ?`,
[newGrit, link.id],
);
}

processedCount++;
}

this.logger.log(
`Daily Grit Meter processing complete. Processed: ${processedCount}, Level Downs: ${levelDownCount}`,
);
} catch (err) {
this.logger.error('Failed processing daily grit meter', err);
throw err;
} finally {
await queryRunner.release();
}

return { processed: processedCount, levelDowns: levelDownCount };
}

private async checkFeatureFlag(): Promise<boolean> {
try {
const result: Array<{ value: string }> = await this.dataSource.query(
`SELECT value FROM system_setting WHERE key = 'grit_meter_enabled' LIMIT 1`,
);
if (!result || result.length === 0) {
return true;
}
return result[0].value.toLowerCase() === 'true';
} catch (err) {
this.logger.warn('Failed to query feature flag, defaulting to true', err);
return true;
}
}

private async sendDiscordWebhook(webhookUrl: string, userId: string): Promise<void> {
try {
const content = `user_role<|=|>update<|=|>${userId}`;
await axios.post(webhookUrl, { content }, { timeout: 10000 });
this.logger.log(`Discord role sync webhook sent for user ${userId}`);
} catch (err) {
this.logger.error(`Failed sending Discord webhook for user ${userId}`, err);
}
}
}
Loading