feat(sprint3): EconomyModule TetardCoin + TwitchModule EventSub — migration + 36 tests
This commit is contained in:
@@ -16,3 +16,7 @@ SUPER_OAUTH_JWT_SECRET=
|
|||||||
|
|
||||||
# Cookie signing
|
# Cookie signing
|
||||||
COOKIE_SECRET=
|
COOKIE_SECRET=
|
||||||
|
|
||||||
|
# Twitch EventSub webhook
|
||||||
|
TWITCH_WEBHOOK_SECRET=<secret EventSub>
|
||||||
|
TWITCH_CLIENT_ID=<app client id>
|
||||||
|
|||||||
80
docs/economy-design.md
Normal file
80
docs/economy-design.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# TetaRdPG — Economy Design : TetardCoin
|
||||||
|
|
||||||
|
> Sprint 3 — Step 1 output
|
||||||
|
> Date : 2026-03-17
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Taux de conversion
|
||||||
|
|
||||||
|
**10 Bits = 1 TetardCoin (TC)**
|
||||||
|
|
||||||
|
Justification : 100 Bits (cheer le plus courant sur Twitch) → 10 TC = 1 recharge endurance complète. Ni trop abondant (1:1 dévaluerait le TC immédiatement), ni trop rare (100:1 pénaliserait les petits cheers). Valeur implicite ~0,10 USD par TC, ancrée sur le cours Bits Twitch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rewards Abonnés
|
||||||
|
|
||||||
|
| Tier | TC / mois |
|
||||||
|
|------|-----------|
|
||||||
|
| Prime | 30 TC |
|
||||||
|
| T1 | 50 TC |
|
||||||
|
| T2 | 120 TC |
|
||||||
|
| T3 | 350 TC |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rewards Bits — Seuils de Cheers
|
||||||
|
|
||||||
|
Base : 10 Bits = 1 TC + prime de seuil
|
||||||
|
|
||||||
|
| Seuil | TC crédité | Prime | Note |
|
||||||
|
|-------|-----------|-------|------|
|
||||||
|
| 100 Bits | 10 TC | 0 | Pas de prime — évite le split-cheering |
|
||||||
|
| 500 Bits | 55 TC | +5 TC | ~10% prime |
|
||||||
|
| 1 000 Bits | 115 TC | +15 TC | ~15% prime |
|
||||||
|
| 5 000 Bits | 575 TC | +75 TC | ~15% prime |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Utilisations TC
|
||||||
|
|
||||||
|
| Usage | Coût TC | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| Recharge endurance | 1 TC = +20 pts | Prime volume : 5 TC et 10 TC |
|
||||||
|
| Cosmétiques Twitch | 20 – 150 TC | Titres, cadres, skins limités — rotation mensuelle |
|
||||||
|
| Forge garantie | Max 12 TC | Supprime risque perte matériaux (20-40%) |
|
||||||
|
| Tickets PvP | 5 TC = +3 tickets | Plafond +10 tickets/jour |
|
||||||
|
| Artisanat accéléré | 1 TC = skip 15 min | Max 8 TC pour un craft de 2h |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sink Anti-Inflation
|
||||||
|
|
||||||
|
**Oui — sinks actifs**
|
||||||
|
|
||||||
|
Sinks primaires :
|
||||||
|
- Endurance (consommation quotidienne si actif)
|
||||||
|
- Forge garantie (usage situationnel fort)
|
||||||
|
- Cosmétiques (rotation crée FOMO)
|
||||||
|
- Artisanat accéléré (usage passif régulier)
|
||||||
|
|
||||||
|
Sink secondaire proposé :
|
||||||
|
- Création de guilde : 50 TC
|
||||||
|
- Upgrade guilde (3 niveaux) : 30 / 60 / 100 TC
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Différenciateur vs StreamElements / Streamlabs Points
|
||||||
|
|
||||||
|
StreamElements/Streamlabs = présence passive → points sans friction → aucune décision.
|
||||||
|
|
||||||
|
TetardCoin = engagement actif → arbitrages réels (forge vs endurance vs guilde) → économie avec tension.
|
||||||
|
|
||||||
|
**C'est la différence entre un programme de fidélité et un jeu.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prochaines étapes → Step 2
|
||||||
|
|
||||||
|
Implémenter : entité TetardCoin (balance + historique), service de conversion Bits→TC, migrations DB, API endpoints (balance, earn, spend, history), tests invariants économiques.
|
||||||
3193
package-lock.json
generated
3193
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -10,7 +10,10 @@
|
|||||||
"seed": "ts-node -r tsconfig-paths/register src/database/seed.ts",
|
"seed": "ts-node -r tsconfig-paths/register src/database/seed.ts",
|
||||||
"seed:monsters": "ts-node -r tsconfig-paths/register src/database/monsters-seed.ts",
|
"seed:monsters": "ts-node -r tsconfig-paths/register src/database/monsters-seed.ts",
|
||||||
"seed:items": "ts-node -r tsconfig-paths/register src/database/items-seed.ts",
|
"seed:items": "ts-node -r tsconfig-paths/register src/database/items-seed.ts",
|
||||||
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts"
|
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"migration:generate": "typeorm-ts-node-commonjs -d src/database/data-source.ts migration:generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
@@ -35,9 +38,29 @@
|
|||||||
"@nestjs/testing": "^10.0.0",
|
"@nestjs/testing": "^10.0.0",
|
||||||
"@types/cookie-parser": "^1.4.6",
|
"@types/cookie-parser": "^1.4.6",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.4.6",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.1.3"
|
"typescript": "^5.1.3"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { ItemModule } from './item/item.module';
|
|||||||
import { MaterialModule } from './material/material.module';
|
import { MaterialModule } from './material/material.module';
|
||||||
import { CraftModule } from './craft/craft.module';
|
import { CraftModule } from './craft/craft.module';
|
||||||
import { ForgeModule } from './forge/forge.module';
|
import { ForgeModule } from './forge/forge.module';
|
||||||
|
import { EconomyModule } from './economy/economy.module';
|
||||||
|
import { TwitchModule } from './twitch/twitch.module';
|
||||||
import { HealthController } from './common/health.controller';
|
import { HealthController } from './common/health.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -43,6 +45,8 @@ import { HealthController } from './common/health.controller';
|
|||||||
MaterialModule,
|
MaterialModule,
|
||||||
CraftModule,
|
CraftModule,
|
||||||
ForgeModule,
|
ForgeModule,
|
||||||
|
EconomyModule,
|
||||||
|
TwitchModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
|
|||||||
13
src/economy/conversion.service.spec.ts
Normal file
13
src/economy/conversion.service.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ConversionService } from './conversion.service';
|
||||||
|
|
||||||
|
describe('ConversionService', () => {
|
||||||
|
const service = new ConversionService();
|
||||||
|
|
||||||
|
it('10 Bits = 1 TC', () => expect(service.bitsToTC(10)).toBe(1));
|
||||||
|
it('15 Bits = 1 TC (floor)', () => expect(service.bitsToTC(15)).toBe(1));
|
||||||
|
it('0 Bits = 0 TC', () => expect(service.bitsToTC(0)).toBe(0));
|
||||||
|
it('9 Bits = 0 TC', () => expect(service.bitsToTC(9)).toBe(0));
|
||||||
|
it('100 Bits = 10 TC', () => expect(service.bitsToTC(100)).toBe(10));
|
||||||
|
it('1000 Bits = 100 TC', () => expect(service.bitsToTC(1000)).toBe(100));
|
||||||
|
it('throws on negative bits', () => expect(() => service.bitsToTC(-1)).toThrow());
|
||||||
|
});
|
||||||
11
src/economy/conversion.service.ts
Normal file
11
src/economy/conversion.service.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
const BITS_PER_TC = 10;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConversionService {
|
||||||
|
bitsToTC(bits: number): number {
|
||||||
|
if (bits < 0) throw new Error('bits cannot be negative');
|
||||||
|
return Math.floor(bits / BITS_PER_TC);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/economy/economy.module.ts
Normal file
13
src/economy/economy.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { TetardCoin } from './entities/tetard-coin.entity';
|
||||||
|
import { Transaction } from './entities/transaction.entity';
|
||||||
|
import { TetardCoinService } from './tetard-coin.service';
|
||||||
|
import { ConversionService } from './conversion.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([TetardCoin, Transaction])],
|
||||||
|
providers: [TetardCoinService, ConversionService],
|
||||||
|
exports: [TetardCoinService, ConversionService],
|
||||||
|
})
|
||||||
|
export class EconomyModule {}
|
||||||
17
src/economy/entities/tetard-coin.entity.ts
Normal file
17
src/economy/entities/tetard-coin.entity.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, UpdateDateColumn, Index } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('tetard_coins')
|
||||||
|
export class TetardCoin {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'varchar', length: 255, unique: true })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0 })
|
||||||
|
balance: number;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
20
src/economy/entities/transaction.entity.ts
Normal file
20
src/economy/entities/transaction.entity.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('tc_transactions')
|
||||||
|
export class Transaction {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'varchar', length: 255 })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
99
src/economy/tetard-coin.service.spec.ts
Normal file
99
src/economy/tetard-coin.service.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { TetardCoinService } from './tetard-coin.service';
|
||||||
|
import { TetardCoin } from './entities/tetard-coin.entity';
|
||||||
|
import { Transaction } from './entities/transaction.entity';
|
||||||
|
|
||||||
|
const makeTcRepo = () => ({
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn((dto) => ({ ...dto })),
|
||||||
|
save: jest.fn(async (entity) => entity),
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeTxRepo = () => ({
|
||||||
|
create: jest.fn((dto) => ({ ...dto })),
|
||||||
|
save: jest.fn(async (entity) => entity),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TetardCoinService', () => {
|
||||||
|
let service: TetardCoinService;
|
||||||
|
let tcRepo: ReturnType<typeof makeTcRepo>;
|
||||||
|
let txRepo: ReturnType<typeof makeTxRepo>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tcRepo = makeTcRepo();
|
||||||
|
txRepo = makeTxRepo();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
TetardCoinService,
|
||||||
|
{ provide: getRepositoryToken(TetardCoin), useValue: tcRepo },
|
||||||
|
{ provide: getRepositoryToken(Transaction), useValue: txRepo },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = module.get<TetardCoinService>(TetardCoinService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('earn()', () => {
|
||||||
|
it('augmente la balance (nouvel utilisateur)', async () => {
|
||||||
|
tcRepo.findOne.mockResolvedValue(null);
|
||||||
|
expect(await service.earn('u1', 10)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('augmente la balance (utilisateur existant)', async () => {
|
||||||
|
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 50 });
|
||||||
|
expect(await service.earn('u1', 30)).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('crée une entrée transaction audit', async () => {
|
||||||
|
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 0 });
|
||||||
|
await service.earn('u1', 20, 'subscription-t1');
|
||||||
|
expect(txRepo.save).toHaveBeenCalledTimes(1);
|
||||||
|
expect(txRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ userId: 'u1', amount: 20, reason: 'subscription-t1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throw si amount <= 0', async () => {
|
||||||
|
await expect(service.earn('u1', 0)).rejects.toThrow(BadRequestException);
|
||||||
|
await expect(service.earn('u1', -5)).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('spend()', () => {
|
||||||
|
it('diminue la balance du montant correct', async () => {
|
||||||
|
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 100 });
|
||||||
|
expect(await service.spend('u1', 30, 'endurance')).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throw si balance insuffisante (invariant ≥ 0)', async () => {
|
||||||
|
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 10 });
|
||||||
|
await expect(service.spend('u1', 15, 'guild')).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throw si balance = 0', async () => {
|
||||||
|
tcRepo.findOne.mockResolvedValue(null);
|
||||||
|
await expect(service.spend('u1', 1, 'cosmetic')).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('crée une transaction négative (audit)', async () => {
|
||||||
|
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 50 });
|
||||||
|
await service.spend('u1', 20, 'forge');
|
||||||
|
expect(txRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ amount: -20, reason: 'forge' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBalance()', () => {
|
||||||
|
it('retourne 0 si aucun record', async () => {
|
||||||
|
tcRepo.findOne.mockResolvedValue(null);
|
||||||
|
expect(await service.getBalance('u1')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne la balance correcte', async () => {
|
||||||
|
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 42 });
|
||||||
|
expect(await service.getBalance('u1')).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/economy/tetard-coin.service.ts
Normal file
52
src/economy/tetard-coin.service.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { TetardCoin } from './entities/tetard-coin.entity';
|
||||||
|
import { Transaction } from './entities/transaction.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TetardCoinService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(TetardCoin)
|
||||||
|
private readonly tcRepo: Repository<TetardCoin>,
|
||||||
|
@InjectRepository(Transaction)
|
||||||
|
private readonly txRepo: Repository<Transaction>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getBalance(userId: string): Promise<number> {
|
||||||
|
const record = await this.tcRepo.findOne({ where: { userId } });
|
||||||
|
return record?.balance ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async earn(userId: string, amount: number, reason = 'earn'): Promise<number> {
|
||||||
|
if (amount <= 0) throw new BadRequestException('earn amount must be > 0');
|
||||||
|
|
||||||
|
let record = await this.tcRepo.findOne({ where: { userId } });
|
||||||
|
if (!record) {
|
||||||
|
record = this.tcRepo.create({ userId, balance: 0 });
|
||||||
|
}
|
||||||
|
record.balance += amount;
|
||||||
|
await this.tcRepo.save(record);
|
||||||
|
await this.txRepo.save(this.txRepo.create({ userId, amount, reason }));
|
||||||
|
return record.balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async spend(userId: string, amount: number, reason: string): Promise<number> {
|
||||||
|
if (amount <= 0) throw new BadRequestException('spend amount must be > 0');
|
||||||
|
|
||||||
|
const record = await this.tcRepo.findOne({ where: { userId } });
|
||||||
|
const current = record?.balance ?? 0;
|
||||||
|
|
||||||
|
// Invariant absolu : balance ne peut jamais être négative
|
||||||
|
if (current < amount) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Insufficient balance: has ${current} TC, needs ${amount} TC`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
record!.balance -= amount;
|
||||||
|
await this.tcRepo.save(record!);
|
||||||
|
await this.txRepo.save(this.txRepo.create({ userId, amount: -amount, reason }));
|
||||||
|
return record!.balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/twitch/entities/processed-event.entity.ts
Normal file
10
src/twitch/entities/processed-event.entity.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Entity, PrimaryColumn, CreateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('processed_events')
|
||||||
|
export class ProcessedEvent {
|
||||||
|
@PrimaryColumn({ type: 'varchar', length: 255 })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'processed_at' })
|
||||||
|
processedAt: Date;
|
||||||
|
}
|
||||||
148
src/twitch/twitch-event.service.spec.ts
Normal file
148
src/twitch/twitch-event.service.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { TwitchEventService } from './twitch-event.service';
|
||||||
|
import { ProcessedEvent } from './entities/processed-event.entity';
|
||||||
|
import { TetardCoinService } from '../economy/tetard-coin.service';
|
||||||
|
import { ConversionService } from '../economy/conversion.service';
|
||||||
|
|
||||||
|
const makeProcessedRepo = () => ({
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn((dto) => ({ ...dto })),
|
||||||
|
save: jest.fn(async (entity) => entity),
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeTcService = () => ({
|
||||||
|
earn: jest.fn(async () => 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TwitchEventService', () => {
|
||||||
|
let service: TwitchEventService;
|
||||||
|
let processedRepo: ReturnType<typeof makeProcessedRepo>;
|
||||||
|
let tcService: ReturnType<typeof makeTcService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
processedRepo = makeProcessedRepo();
|
||||||
|
tcService = makeTcService();
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
TwitchEventService,
|
||||||
|
ConversionService,
|
||||||
|
{ provide: getRepositoryToken(ProcessedEvent), useValue: processedRepo },
|
||||||
|
{ provide: TetardCoinService, useValue: tcService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<TwitchEventService>(TwitchEventService);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Cheer tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('handleCheer()', () => {
|
||||||
|
it('cheer 100 Bits → earn 10 TC (taux 10:1, pas de prime)', async () => {
|
||||||
|
processedRepo.findOne.mockResolvedValue(null);
|
||||||
|
await service.handleCheer('msg-1', 'user1', 100);
|
||||||
|
expect(tcService.earn).toHaveBeenCalledWith('user1', 10, 'cheer:100bits');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cheer 500 Bits → earn 55 TC (50 base + 5 prime seuil)', async () => {
|
||||||
|
processedRepo.findOne.mockResolvedValue(null);
|
||||||
|
await service.handleCheer('msg-2', 'user1', 500);
|
||||||
|
expect(tcService.earn).toHaveBeenCalledWith('user1', 55, 'cheer:500bits');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cheer 1000 Bits → earn 115 TC (100 base + 15 prime seuil)', async () => {
|
||||||
|
processedRepo.findOne.mockResolvedValue(null);
|
||||||
|
await service.handleCheer('msg-3', 'user1', 1000);
|
||||||
|
expect(tcService.earn).toHaveBeenCalledWith('user1', 115, 'cheer:1000bits');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cheer 5000 Bits → earn 575 TC (500 base + 75 prime seuil)', async () => {
|
||||||
|
processedRepo.findOne.mockResolvedValue(null);
|
||||||
|
await service.handleCheer('msg-4', 'user1', 5000);
|
||||||
|
expect(tcService.earn).toHaveBeenCalledWith('user1', 575, 'cheer:5000bits');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Idempotence tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('idempotence', () => {
|
||||||
|
it('event déjà traité → pas de double crédit (cheer)', async () => {
|
||||||
|
processedRepo.findOne.mockResolvedValue({ id: 'msg-dup', processedAt: new Date() });
|
||||||
|
await service.handleCheer('msg-dup', 'user1', 100);
|
||||||
|
expect(tcService.earn).not.toHaveBeenCalled();
|
||||||
|
// Must not insert again
|
||||||
|
expect(processedRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('event déjà traité → pas de double crédit (subscription)', async () => {
|
||||||
|
processedRepo.findOne.mockResolvedValue({ id: 'msg-sub-dup', processedAt: new Date() });
|
||||||
|
await service.handleSubscription('msg-sub-dup', 'user1', '1000', false);
|
||||||
|
expect(tcService.earn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('premier event → inséré dans ProcessedEvent', async () => {
|
||||||
|
processedRepo.findOne.mockResolvedValue(null);
|
||||||
|
await service.handleCheer('msg-new', 'user1', 100);
|
||||||
|
expect(processedRepo.save).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Subscription tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('handleSubscription()', () => {
|
||||||
|
it('T1 subscribe → earn 50 TC', async () => {
|
||||||
|
processedRepo.findOne.mockResolvedValue(null);
|
||||||
|
await service.handleSubscription('msg-s1', 'user1', '1000', false);
|
||||||
|
expect(tcService.earn).toHaveBeenCalledWith('user1', 50, 'subscribe:tier1000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T2 subscribe → earn 120 TC', async () => {
|
||||||
|
processedRepo.findOne.mockResolvedValue(null);
|
||||||
|
await service.handleSubscription('msg-s2', 'user1', '2000', false);
|
||||||
|
expect(tcService.earn).toHaveBeenCalledWith('user1', 120, 'subscribe:tier2000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T3 subscribe → earn 350 TC', async () => {
|
||||||
|
processedRepo.findOne.mockResolvedValue(null);
|
||||||
|
await service.handleSubscription('msg-s3', 'user1', '3000', false);
|
||||||
|
expect(tcService.earn).toHaveBeenCalledWith('user1', 350, 'subscribe:tier3000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Prime subscribe → earn 30 TC', async () => {
|
||||||
|
processedRepo.findOne.mockResolvedValue(null);
|
||||||
|
await service.handleSubscription('msg-sp', 'user1', 'prime', false);
|
||||||
|
expect(tcService.earn).toHaveBeenCalledWith('user1', 30, 'subscribe:tierprime');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gift sub → gifter earns TC (gifterUserId)', async () => {
|
||||||
|
processedRepo.findOne.mockResolvedValue(null);
|
||||||
|
await service.handleSubscription('msg-gift', 'recipient', '1000', true, 'gifter1');
|
||||||
|
expect(tcService.earn).toHaveBeenCalledWith('gifter1', 50, 'gift-sub:tier1000');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Threshold bonus unit tests ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getThresholdBonus()', () => {
|
||||||
|
it('< 500 Bits → bonus 0', () => {
|
||||||
|
expect(service.getThresholdBonus(100)).toBe(0);
|
||||||
|
expect(service.getThresholdBonus(499)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('500 Bits → bonus +5', () => {
|
||||||
|
expect(service.getThresholdBonus(500)).toBe(5);
|
||||||
|
expect(service.getThresholdBonus(999)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('1000 Bits → bonus +15', () => {
|
||||||
|
expect(service.getThresholdBonus(1000)).toBe(15);
|
||||||
|
expect(service.getThresholdBonus(4999)).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5000 Bits → bonus +75', () => {
|
||||||
|
expect(service.getThresholdBonus(5000)).toBe(75);
|
||||||
|
expect(service.getThresholdBonus(10000)).toBe(75);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
96
src/twitch/twitch-event.service.ts
Normal file
96
src/twitch/twitch-event.service.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { TetardCoinService } from '../economy/tetard-coin.service';
|
||||||
|
import { ConversionService } from '../economy/conversion.service';
|
||||||
|
import { ProcessedEvent } from './entities/processed-event.entity';
|
||||||
|
|
||||||
|
// Subscription tier rewards — economy-design.md
|
||||||
|
const SUB_TC: Record<string, number> = {
|
||||||
|
'1000': 50, // T1
|
||||||
|
'2000': 120, // T2
|
||||||
|
'3000': 350, // T3
|
||||||
|
prime: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bits threshold bonuses — economy-design.md
|
||||||
|
const BITS_BONUS: Array<{ threshold: number; bonus: number }> = [
|
||||||
|
{ threshold: 5000, bonus: 75 },
|
||||||
|
{ threshold: 1000, bonus: 15 },
|
||||||
|
{ threshold: 500, bonus: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TwitchEventService {
|
||||||
|
private readonly logger = new Logger(TwitchEventService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ProcessedEvent)
|
||||||
|
private readonly processedRepo: Repository<ProcessedEvent>,
|
||||||
|
private readonly tcService: TetardCoinService,
|
||||||
|
private readonly conversionService: ConversionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if already processed (idempotence guard).
|
||||||
|
* If not seen before, inserts and returns false.
|
||||||
|
*/
|
||||||
|
async isAlreadyProcessed(messageId: string): Promise<boolean> {
|
||||||
|
const existing = await this.processedRepo.findOne({ where: { id: messageId } });
|
||||||
|
if (existing) return true;
|
||||||
|
|
||||||
|
await this.processedRepo.save(this.processedRepo.create({ id: messageId }));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* channel.cheer — convert bits to TC with threshold bonus, then earn.
|
||||||
|
*/
|
||||||
|
async handleCheer(messageId: string, userId: string, bits: number): Promise<void> {
|
||||||
|
if (await this.isAlreadyProcessed(messageId)) {
|
||||||
|
this.logger.log(`Idempotent skip — messageId=${messageId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseTC = this.conversionService.bitsToTC(bits);
|
||||||
|
const bonus = this.getThresholdBonus(bits);
|
||||||
|
const total = baseTC + bonus;
|
||||||
|
|
||||||
|
await this.tcService.earn(userId, total, `cheer:${bits}bits`);
|
||||||
|
this.logger.log(`Cheer processed: userId=${userId} bits=${bits} tc=${total} (base=${baseTC} bonus=${bonus})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* channel.subscribe / channel.subscription.gift
|
||||||
|
* gifted: if true, gifter earns TC (senderUserId), recipient earns nothing.
|
||||||
|
*/
|
||||||
|
async handleSubscription(
|
||||||
|
messageId: string,
|
||||||
|
userId: string,
|
||||||
|
tier: string,
|
||||||
|
gifted = false,
|
||||||
|
gifterUserId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (await this.isAlreadyProcessed(messageId)) {
|
||||||
|
this.logger.log(`Idempotent skip — messageId=${messageId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tc = SUB_TC[tier] ?? SUB_TC['1000'];
|
||||||
|
const earnUserId = gifted && gifterUserId ? gifterUserId : userId;
|
||||||
|
const reason = gifted ? `gift-sub:tier${tier}` : `subscribe:tier${tier}`;
|
||||||
|
|
||||||
|
await this.tcService.earn(earnUserId, tc, reason);
|
||||||
|
this.logger.log(`Subscription processed: userId=${earnUserId} tier=${tier} tc=${tc} gifted=${gifted}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute threshold bonus based on economy-design.md table.
|
||||||
|
*/
|
||||||
|
getThresholdBonus(bits: number): number {
|
||||||
|
for (const { threshold, bonus } of BITS_BONUS) {
|
||||||
|
if (bits >= threshold) return bonus;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/twitch/twitch-webhook.controller.spec.ts
Normal file
97
src/twitch/twitch-webhook.controller.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createHmac } from 'crypto';
|
||||||
|
import { TwitchWebhookController } from './twitch-webhook.controller';
|
||||||
|
import { TwitchEventService } from './twitch-event.service';
|
||||||
|
|
||||||
|
const SECRET = 'test-webhook-secret';
|
||||||
|
|
||||||
|
const makeEventService = () => ({
|
||||||
|
handleCheer: jest.fn(),
|
||||||
|
handleSubscription: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeConfigService = () => ({
|
||||||
|
get: jest.fn((key: string) => (key === 'TWITCH_WEBHOOK_SECRET' ? SECRET : undefined)),
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildRequest(messageId: string, timestamp: string, bodyStr: string) {
|
||||||
|
const rawBody = Buffer.from(bodyStr, 'utf8');
|
||||||
|
const hmac = `sha256=${createHmac('sha256', SECRET)
|
||||||
|
.update(`${messageId}${timestamp}${bodyStr}`)
|
||||||
|
.digest('hex')}`;
|
||||||
|
|
||||||
|
return { rawBody, signature: hmac };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TwitchWebhookController — signature', () => {
|
||||||
|
let controller: TwitchWebhookController;
|
||||||
|
let eventService: ReturnType<typeof makeEventService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
eventService = makeEventService();
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [TwitchWebhookController],
|
||||||
|
providers: [
|
||||||
|
{ provide: TwitchEventService, useValue: eventService },
|
||||||
|
{ provide: ConfigService, useValue: makeConfigService() },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<TwitchWebhookController>(TwitchWebhookController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signature valide → traite la requête sans erreur', async () => {
|
||||||
|
const msgId = 'valid-msg-id';
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
const bodyObj = {
|
||||||
|
subscription: { type: 'channel.cheer' },
|
||||||
|
event: { user_id: 'user1', bits_used: 100 },
|
||||||
|
};
|
||||||
|
const bodyStr = JSON.stringify(bodyObj);
|
||||||
|
const { rawBody, signature } = buildRequest(msgId, ts, bodyStr);
|
||||||
|
|
||||||
|
const req = { rawBody } as any;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.handleWebhook(req, msgId, ts, signature, 'notification', bodyObj as any),
|
||||||
|
).resolves.toEqual({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signature invalide → ForbiddenException (400/403)', async () => {
|
||||||
|
const msgId = 'invalid-msg-id';
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
const bodyObj = {
|
||||||
|
subscription: { type: 'channel.cheer' },
|
||||||
|
event: { user_id: 'user1', bits_used: 100 },
|
||||||
|
};
|
||||||
|
const bodyStr = JSON.stringify(bodyObj);
|
||||||
|
const rawBody = Buffer.from(bodyStr, 'utf8');
|
||||||
|
const badSignature = 'sha256=invalidsignaturevalue00000000000000000000000000000000000000000000';
|
||||||
|
|
||||||
|
const req = { rawBody } as any;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.handleWebhook(req, msgId, ts, badSignature, 'notification', bodyObj as any),
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
|
||||||
|
expect(eventService.handleCheer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signature manquante → ForbiddenException', async () => {
|
||||||
|
const msgId = 'no-sig-msg';
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
const bodyObj = { subscription: { type: 'channel.cheer' }, event: {} };
|
||||||
|
const bodyStr = JSON.stringify(bodyObj);
|
||||||
|
const rawBody = Buffer.from(bodyStr, 'utf8');
|
||||||
|
|
||||||
|
const req = { rawBody } as any;
|
||||||
|
|
||||||
|
// Pass empty string as signature — length mismatch → ForbiddenException
|
||||||
|
await expect(
|
||||||
|
controller.handleWebhook(req, msgId, ts, '', 'notification', bodyObj as any),
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
src/twitch/twitch-webhook.controller.ts
Normal file
123
src/twitch/twitch-webhook.controller.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Headers,
|
||||||
|
Body,
|
||||||
|
RawBodyRequest,
|
||||||
|
Req,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { TwitchEventService } from './twitch-event.service';
|
||||||
|
|
||||||
|
@Controller('twitch')
|
||||||
|
export class TwitchWebhookController {
|
||||||
|
private readonly logger = new Logger(TwitchWebhookController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly twitchEventService: TwitchEventService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('webhook')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async handleWebhook(
|
||||||
|
@Req() req: RawBodyRequest<Request>,
|
||||||
|
@Headers('twitch-eventsub-message-id') messageId: string,
|
||||||
|
@Headers('twitch-eventsub-message-timestamp') timestamp: string,
|
||||||
|
@Headers('twitch-eventsub-message-signature') signature: string,
|
||||||
|
@Headers('twitch-eventsub-message-type') messageType: string,
|
||||||
|
@Body() body: Record<string, unknown>,
|
||||||
|
): Promise<{ status: string }> {
|
||||||
|
// Validate HMAC-SHA256 signature — security: validate-then-verify pattern
|
||||||
|
this.verifySignature(req, messageId, timestamp, signature);
|
||||||
|
|
||||||
|
// Twitch webhook verification challenge
|
||||||
|
if (messageType === 'webhook_callback_verification') {
|
||||||
|
return { status: body['challenge'] as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType === 'revocation') {
|
||||||
|
this.logger.warn(`Subscription revoked for messageId=${messageId}`);
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType !== 'notification') {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = body['subscription'] as Record<string, unknown>;
|
||||||
|
const event = body['event'] as Record<string, unknown>;
|
||||||
|
const eventType = (subscription?.['type'] as string) ?? '';
|
||||||
|
|
||||||
|
switch (eventType) {
|
||||||
|
case 'channel.cheer':
|
||||||
|
await this.handleCheer(messageId, event);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'channel.subscribe':
|
||||||
|
await this.handleSubscribe(messageId, event);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'channel.subscription.gift':
|
||||||
|
await this.handleGiftSub(messageId, event);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
this.logger.debug(`Unhandled event type: ${eventType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private verifySignature(
|
||||||
|
req: RawBodyRequest<Request>,
|
||||||
|
messageId: string,
|
||||||
|
timestamp: string,
|
||||||
|
signature: string,
|
||||||
|
): void {
|
||||||
|
const secret = this.configService.get<string>('TWITCH_WEBHOOK_SECRET');
|
||||||
|
if (!secret) throw new ForbiddenException('Webhook secret not configured');
|
||||||
|
|
||||||
|
const rawBody = req.rawBody;
|
||||||
|
if (!rawBody) throw new ForbiddenException('Raw body unavailable');
|
||||||
|
|
||||||
|
const hmacMessage = `${messageId}${timestamp}${rawBody.toString('utf8')}`;
|
||||||
|
const expected = `sha256=${createHmac('sha256', secret).update(hmacMessage).digest('hex')}`;
|
||||||
|
|
||||||
|
// timingSafeEqual prevents timing attacks
|
||||||
|
const sigBuf = Buffer.from(signature ?? '');
|
||||||
|
const expBuf = Buffer.from(expected);
|
||||||
|
|
||||||
|
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
|
||||||
|
this.logger.warn(`Invalid signature for messageId=${messageId}`);
|
||||||
|
throw new ForbiddenException('Invalid webhook signature');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCheer(messageId: string, event: Record<string, unknown>): Promise<void> {
|
||||||
|
const userId = event['user_id'] as string;
|
||||||
|
const bits = Number(event['bits_used'] ?? 0);
|
||||||
|
await this.twitchEventService.handleCheer(messageId, userId, bits);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSubscribe(messageId: string, event: Record<string, unknown>): Promise<void> {
|
||||||
|
const userId = event['user_id'] as string;
|
||||||
|
const tier = event['tier'] as string;
|
||||||
|
await this.twitchEventService.handleSubscription(messageId, userId, tier, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleGiftSub(messageId: string, event: Record<string, unknown>): Promise<void> {
|
||||||
|
const gifterUserId = event['user_id'] as string;
|
||||||
|
const tier = event['tier'] as string;
|
||||||
|
// For gifts, gifter earns TC
|
||||||
|
await this.twitchEventService.handleSubscription(messageId, gifterUserId, tier, true, gifterUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/twitch/twitch.module.ts
Normal file
19
src/twitch/twitch.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { EconomyModule } from '../economy/economy.module';
|
||||||
|
import { ProcessedEvent } from './entities/processed-event.entity';
|
||||||
|
import { TwitchEventService } from './twitch-event.service';
|
||||||
|
import { TwitchWebhookController } from './twitch-webhook.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule,
|
||||||
|
TypeOrmModule.forFeature([ProcessedEvent]),
|
||||||
|
EconomyModule,
|
||||||
|
],
|
||||||
|
controllers: [TwitchWebhookController],
|
||||||
|
providers: [TwitchEventService],
|
||||||
|
exports: [TwitchEventService],
|
||||||
|
})
|
||||||
|
export class TwitchModule {}
|
||||||
Reference in New Issue
Block a user