first (part 3: src directory)
Deploy Iddaai Backend / build-and-deploy (push) Successful in 33s

This commit is contained in:
2026-04-16 15:12:27 +03:00
parent 2f0b85a0c7
commit 182f4aae16
125 changed files with 22552 additions and 0 deletions
+238
View File
@@ -0,0 +1,238 @@
import {
Controller,
Post,
Get,
Body,
Query,
HttpCode,
HttpStatus,
UseGuards,
Req,
Logger,
} from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse,
} from '@nestjs/swagger';
import { CouponsService } from './coupons.service';
import { MatchesService } from '../matches/matches.service';
import { SmartCouponService } from './services/smart-coupon.service';
import {
UserCouponService,
CreateCouponDto,
} from './services/user-coupon.service';
import {
AnalyzeMatchDto,
DailyBankoDto,
SuggestCouponDto,
} from './dto/coupons-request.dto';
import { Public } from '../../common/decorators';
import { JwtAuthGuard } from '../auth/guards/auth.guards'; // Assuming standard guard
import { Sport } from '../matches/dto';
@ApiTags('Coupon')
@Controller('coupon')
export class CouponsController {
private readonly logger = new Logger(CouponsController.name);
constructor(
private readonly couponsService: CouponsService,
private readonly smartCouponService: SmartCouponService,
private readonly userCouponService: UserCouponService,
private readonly matchesService: MatchesService,
) {}
/**
* POST /coupon/analyze-match
* Analyze a single match with V20+ single-match package
*/
@Post('analyze-match')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Analyze single match with V20 model' })
@ApiResponse({ status: 200, description: 'Match analysis' })
async analyzeMatch(@Body() dto: AnalyzeMatchDto) {
const analysis = await this.smartCouponService.analyzeMatch(dto.matchId);
if (!analysis) {
return { success: false, message: 'Analiz yapılamadı.' };
}
return { success: true, data: analysis };
}
/**
* POST /coupon/analyze (alias for /analyze-match - frontend compatibility)
*/
@Post('analyze')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Analyze single match with V20 model (alias)',
deprecated: true,
})
async analyzeMatchAlias(@Body() dto: AnalyzeMatchDto) {
return this.analyzeMatch(dto);
}
/**
* POST /coupon
* Alias for /coupon/create - frontend compatibility
*/
@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create and save a user coupon (alias)' })
async createCouponAlias(@Body() dto: CreateCouponDto, @Req() req: any) {
return this.createCoupon(dto, req);
}
/**
* POST /coupon/daily-banko
* Generate a high-confidence banko combo (2 matches)
*/
@Post('daily-banko')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Generate a high-confidence banko combo (2 matches)',
})
async getDailyBanko(@Body() dto: DailyBankoDto) {
// If no match IDs provided, fetch from system (top 50 upcoming)
let candidateMatches = dto.matchIds || [];
if (candidateMatches.length === 0) {
candidateMatches = await this.matchesService.findUpcomingMatches(
Sport.FOOTBALL,
20,
);
this.logger.debug(
`Auto-fetched ${candidateMatches.length} matches for daily-banko`,
);
} else {
candidateMatches = await this.matchesService.filterUpcomingMatchIds(
candidateMatches,
Sport.FOOTBALL,
);
this.logger.debug(
`Sanitized candidate matches for daily-banko: ${candidateMatches.length}`,
);
}
if (candidateMatches.length === 0) {
return {
success: false,
message: 'Kupon için uygun, henüz başlamamış maç bulunamadı.',
};
}
const coupon =
await this.smartCouponService.generateDailyBankoCoupon(candidateMatches);
if (!coupon) {
return {
success: false,
message: 'Kriterlere uygun (80%+ güvenli) yeterli maç bulunamadı.',
};
}
return { success: true, data: coupon };
}
/**
* POST /coupon/suggest
* Generate Smart Coupon
*/
@Post('suggest')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Suggest Smart Coupon' })
@ApiResponse({ status: 200, description: 'Smart Coupon generated' })
async suggestCoupon(@Body() dto: SuggestCouponDto) {
// If no match IDs provided, fetch from system (top 50 upcoming)
let candidateMatches = dto.matchIds || [];
if (candidateMatches.length === 0) {
candidateMatches = await this.matchesService.findUpcomingMatches(
Sport.FOOTBALL,
20,
);
this.logger.debug(
`Auto-fetched ${candidateMatches.length} matches for suggest`,
);
} else {
candidateMatches = await this.matchesService.filterUpcomingMatchIds(
candidateMatches,
Sport.FOOTBALL,
);
this.logger.debug(
`Sanitized candidate matches for suggest: ${candidateMatches.length}`,
);
}
if (candidateMatches.length === 0) {
return {
success: false,
message: 'Tahmin için uygun, henüz başlamamış maç bulunamadı.',
};
}
const coupon = await this.smartCouponService.getSmartCoupon(
candidateMatches,
dto.strategy,
{
maxMatches: dto.maxMatches,
minConfidence: dto.minConfidence,
},
);
if (!coupon) {
return { success: false, message: 'Kupon oluşturulamadı.' };
}
return { success: true, data: coupon };
}
// ============================================
// USER COUPON ENDPOINTS (NEW)
// ============================================
/**
* POST /coupon/create
* Save a user generated coupon
*/
@Post('create')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create and save a user coupon' })
async createCoupon(@Body() dto: CreateCouponDto, @Req() req: any) {
// req.user is populated by JwtAuthGuard
const coupon = await this.userCouponService.createCoupon(req.user, dto);
return { success: true, data: coupon };
}
/**
* GET /coupon/my-stats
* Get user betting statistics (ROI, Win Rate)
*/
@Get('my-stats')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get user betting statistics' })
async getUserStats(@Req() req: any) {
const stats = await this.userCouponService.getUserStatistics(req.user.id);
return { success: true, data: stats };
}
/**
* GET /coupon/history
* Get coupon history (Public/System coupons)
*/
@Get('history')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get coupon history' })
@ApiResponse({ status: 200, description: 'History retrieved' })
async getHistory(@Query('limit') limit?: string) {
// eslint-disable-next-line @typescript-eslint/await-thenable
const results = await this.couponsService.getCouponHistory(
Number(limit) || 10,
);
return { success: true, data: results };
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { CouponsController } from './coupons.controller';
import { SmartCouponService } from './services/smart-coupon.service';
import { UserCouponService } from './services/user-coupon.service';
import { CouponsService } from './coupons.service';
import { DatabaseModule } from '../../database/database.module';
import { ServicesModule } from '../../services/services.module';
import { MatchesModule } from '../matches/matches.module';
@Module({
imports: [DatabaseModule, ServicesModule, MatchesModule],
controllers: [CouponsController],
providers: [CouponsService, SmartCouponService, UserCouponService],
exports: [CouponsService, SmartCouponService, UserCouponService],
})
export class CouponsModule {}
+38
View File
@@ -0,0 +1,38 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { AiService } from '../../services/ai.service';
// [REMOVED V16 IMPORTS]
export type RiskLevel = 'banko' | 'safe' | 'value';
export interface CouponMatch {
matchId: string;
matchName: string;
prediction: string;
confidence: number;
odd: number;
}
export interface GeneratedCoupon {
id: string;
matches: CouponMatch[];
totalOdd: number;
riskLevel: RiskLevel;
generatedAt: string;
}
@Injectable()
export class CouponsService {
private readonly logger = new Logger(CouponsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly aiService: AiService,
) {}
/**
* Legacy history/history methods...
*/
getCouponHistory(_limit: number = 10) {
return [];
}
}
@@ -0,0 +1,76 @@
import {
IsArray,
IsString,
IsOptional,
IsNotEmpty,
IsNumber,
IsEnum,
ArrayMaxSize,
Min,
Max,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum CouponStrategyEnum {
SAFE = 'SAFE',
BALANCED = 'BALANCED',
AGGRESSIVE = 'AGGRESSIVE',
VALUE = 'VALUE',
MIRACLE = 'MIRACLE',
}
export class AnalyzeMatchDto {
@ApiProperty({ description: 'Match ID to analyze' })
@IsString()
@IsNotEmpty()
matchId: string;
}
export class DailyBankoDto {
@ApiPropertyOptional({
description: 'Optional match IDs — system fetches if empty',
example: ['match-1', 'match-2'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(50)
matchIds?: string[];
}
export class SuggestCouponDto {
@ApiPropertyOptional({
description: 'Match IDs — system fetches if empty',
example: ['match-1', 'match-2'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(50)
matchIds?: string[];
@ApiPropertyOptional({
enum: CouponStrategyEnum,
default: CouponStrategyEnum.BALANCED,
})
@IsOptional()
@IsEnum(CouponStrategyEnum)
strategy?: CouponStrategyEnum;
@ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 })
@IsOptional()
@IsNumber()
@Min(1)
@Max(20)
maxMatches?: number;
@ApiPropertyOptional({
description: 'Minimum confidence threshold (0-100)',
example: 60,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
minConfidence?: number;
}
+248
View File
@@ -0,0 +1,248 @@
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { GeminiService } from '../../gemini/gemini.service';
export type PredictionRiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
export type PredictionDataQuality = 'HIGH' | 'MEDIUM' | 'LOW';
export type BetGrade = 'A' | 'B' | 'C' | 'PASS';
export interface PredictionPickRow {
market: string;
pick: string;
probability: number;
confidence: number;
odds: number;
raw_confidence: number;
calibrated_confidence: number;
min_required_confidence: number;
edge: number;
play_score: number;
playable: boolean;
bet_grade: BetGrade;
stake_units: number;
decision_reasons: string[];
}
export interface PredictionBetSummaryRow {
market: string;
pick: string;
raw_confidence: number;
calibrated_confidence: number;
bet_grade: BetGrade;
playable: boolean;
stake_units: number;
play_score: number;
reasons: string[];
}
export interface SingleMatchPredictionPackage {
model_version: string;
match_info: {
match_id: string;
match_name: string;
home_team: string;
away_team: string;
league: string;
match_date_ms: number;
};
data_quality: {
label: PredictionDataQuality;
score: number;
flags: string[];
home_lineup_count: number;
away_lineup_count: number;
};
risk: {
level: PredictionRiskLevel;
score: number;
is_surprise_risk: boolean;
surprise_type: string | null;
warnings: string[];
};
engine_breakdown: {
team: number;
player: number;
odds: number;
referee: number;
};
main_pick: PredictionPickRow | null;
value_pick: PredictionPickRow | null;
bet_advice: {
playable: boolean;
suggested_stake_units: number;
reason: string;
};
bet_summary: PredictionBetSummaryRow[];
supporting_picks: PredictionPickRow[];
aggressive_pick: {
market: string;
pick: string;
probability: number;
confidence: number;
odds: number | null;
} | null;
scenario_top5: Array<{
score: string;
prob: number;
[key: string]: unknown;
}>;
score_prediction: {
ft: string;
ht: string;
xg_home: number;
xg_away: number;
xg_total: number;
};
market_board: Record<string, unknown>;
reasoning_factors: string[];
ai_commentary?: string | null;
}
export interface SmartCouponResult {
strategy: string;
generated_at: string;
match_count: number;
bets: Array<{
match_id: string;
match_name: string;
market: string;
pick: string;
probability: number;
confidence: number;
odds: number;
risk_level: PredictionRiskLevel;
data_quality: PredictionDataQuality;
}>;
total_odds: number;
expected_win_rate: number;
rejected_matches: Array<{
match_id: string;
reason: string;
threshold?: number;
}>;
}
@Injectable()
export class SmartCouponService {
private readonly logger = new Logger(SmartCouponService.name);
private readonly aiEngineUrl: string;
constructor(private readonly geminiService: GeminiService) {
this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000';
}
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
let prediction: SingleMatchPredictionPackage;
try {
const response = await axios.post<SingleMatchPredictionPackage>(
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
);
prediction = response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const detail = error.response?.data?.detail || error.message;
throw new HttpException(
`AI analyze failed: ${detail}`,
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
);
}
throw new HttpException(
'AI analyze failed',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Generate AI commentary (non-blocking — fail-safe)
prediction.ai_commentary = await this.generateMatchCommentary(prediction);
return prediction;
}
private async generateMatchCommentary(
prediction: SingleMatchPredictionPackage,
): Promise<string | null> {
if (!this.geminiService.isAvailable()) {
return null;
}
try {
const result = await this.geminiService.generateText(
JSON.stringify(prediction, null, 2),
{
model: 'gemini-2.0-flash',
temperature: 0.7,
maxTokens: 600,
systemPrompt: MATCH_COMMENTARY_SYSTEM_PROMPT,
},
);
return result.text || null;
} catch (error) {
this.logger.warn('AI commentary generation failed, skipping', error);
return null;
}
}
async generateDailyBankoCoupon(
matchIds: string[],
): Promise<SmartCouponResult | null> {
if (matchIds.length === 0) {
return null;
}
return this.getSmartCoupon(matchIds, 'SAFE', {
maxMatches: 2,
minConfidence: 78,
});
}
async getSmartCoupon(
matchIds: string[],
strategy:
| 'SAFE'
| 'BALANCED'
| 'AGGRESSIVE'
| 'VALUE'
| 'MIRACLE' = 'BALANCED',
options: { maxMatches?: number; minConfidence?: number } = {},
): Promise<SmartCouponResult> {
try {
const response = await axios.post<SmartCouponResult>(
`${this.aiEngineUrl}/v20plus/coupon`,
{
match_ids: matchIds,
strategy,
max_matches: options.maxMatches,
min_confidence: options.minConfidence,
},
);
return response.data;
} catch (error) {
this.logger.error('Failed to generate smart coupon', error);
if (axios.isAxiosError(error)) {
const detail = error.response?.data?.detail || error.message;
throw new HttpException(
`Coupon generation failed: ${detail}`,
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
);
}
throw new HttpException(
'Coupon generation failed',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}
const MATCH_COMMENTARY_SYSTEM_PROMPT = `Sen uzman bir futbol bahis analistisin. Sana verilen model çıktısını analiz edip kısa, net ve aksiyon odaklı Türkçe bir yorum yaz.
Kurallar:
- Max 3-4 kısa paragraf, gereksiz uzatma
- Playable olan marketleri ve nedenlerini açıkla
- Edge pozitif olan marketleri vurgula (bahisçiden daha iyi biliyoruz)
- Tüm edge'ler negatifse "trap maç" olarak uyar
- xG ve skor senaryolarına göre strateji öner
- Bahis grade'lerini açıkla: A = güvenli, B = iyi, PASS = oynama
- Data quality ve risk seviyesini yorumla (kadro onaylı mı, probable XI mi)
- "Ben olsam..." formatında kişisel tavsiye ver
- Emoji kullan: ⚽ ✅ ⚠️ 🎯 ❌ 💰
- Markdown formatı KULLANMA, düz metin yaz
- Bahis terminolojisi kullan: edge, value, implied odds, xG`;
+189
View File
@@ -0,0 +1,189 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../database/prisma.service';
import { User, UserCoupon, Match } from '@prisma/client';
export class CreateCouponDto {
strategy: string; // 'SAFE', 'VALUE', 'CUSTOM'
items: {
matchId: string;
selection: string; // 'MS 1', '2.5 UST'
odd: number;
}[];
isPublic?: boolean;
}
export interface UserStatsDto {
totalCoupons: number;
wonCoupons: number;
winRate: number; // Percentage
totalInvested: number; // Unit based (1 unit per coupon)
totalReturn: number;
roi: number; // Return on Investment %
}
@Injectable()
export class UserCouponService {
private readonly logger = new Logger(UserCouponService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Kullanıcı için yeni bir kupon oluşturur ve kaydeder.
*/
async createCoupon(user: User, dto: CreateCouponDto): Promise<UserCoupon> {
const totalOdds = dto.items.reduce((acc, item) => acc * item.odd, 1);
const coupon = await this.prisma.userCoupon.create({
data: {
userId: user.id,
strategy: dto.strategy,
totalOdds: parseFloat(totalOdds.toFixed(2)),
isPublic: dto.isPublic || false,
status: 'PENDING',
couponItems: {
create: dto.items.map((item) => ({
matchId: item.matchId,
selection: item.selection,
oddAtTime: item.odd,
})),
},
},
include: {
couponItems: true,
},
});
this.logger.log(
`Coupon created for user ${user.email} with odds ${totalOdds}`,
);
return coupon;
}
/**
* Bekleyen kuponların sonuçlarını kontrol eder ve günceller.
* Bu metod bir Cron Job tarafından periyodik olarak çağrılmalıdır.
*/
async updatePendingCoupons(): Promise<void> {
// Sadece bitmiş (FT) maçları içeren PENDING kuponları çek
const pendingCoupons = await this.prisma.userCoupon.findMany({
where: { status: 'PENDING' },
include: {
couponItems: {
include: { match: true },
},
},
});
for (const coupon of pendingCoupons) {
let isCouponWon = true;
let isCouponLost = false;
let allMatchesFinished = true;
for (const item of coupon.couponItems) {
if (item.match.status !== 'FT') {
allMatchesFinished = false;
break; // Henüz bitmemiş maç var, kuponu güncelleme
}
const isItemWon = this.checkSelection(item.selection, item.match);
// Sonucu item bazında güncelle
if (item.isCorrect !== isItemWon) {
await this.prisma.userCouponItem.update({
where: { id: item.id },
data: { isCorrect: isItemWon },
});
}
if (!isItemWon) {
isCouponLost = true;
isCouponWon = false;
}
}
if (isCouponLost) {
await this.prisma.userCoupon.update({
where: { id: coupon.id },
data: { status: 'LOST' },
});
} else if (allMatchesFinished && isCouponWon) {
await this.prisma.userCoupon.update({
where: { id: coupon.id },
data: { status: 'WON' },
});
}
}
}
/**
* Basit bir kural seti ile bahsin tutup tutmadığını kontrol eder.
* Gerçek dünyada bu daha karmaşık bir 'BetSettlementService' olmalıdır.
*/
private checkSelection(selection: string, match: Match): boolean {
const home = match.scoreHome ?? 0;
const away = match.scoreAway ?? 0;
const total = home + away;
switch (selection) {
case 'MS 1':
return home > away;
case 'MS X':
return home === away;
case 'MS 2':
return away > home;
case '1.5 UST':
return total > 1.5;
case '2.5 UST':
return total > 2.5;
case '3.5 UST':
return total > 3.5;
case '2.5 ALT':
return total < 2.5;
case 'KG VAR':
return home > 0 && away > 0;
case 'KG YOK':
return home === 0 || away === 0;
default:
return false; // Bilinmeyen market
}
}
/**
* Kullanıcının bahis performans istatistiklerini getirir.
*/
async getUserStatistics(userId: string): Promise<UserStatsDto> {
const coupons = await this.prisma.userCoupon.findMany({
where: {
userId,
status: { in: ['WON', 'LOST'] },
},
});
const totalCoupons = coupons.length;
if (totalCoupons === 0) {
return {
totalCoupons: 0,
wonCoupons: 0,
winRate: 0,
totalInvested: 0,
totalReturn: 0,
roi: 0,
};
}
const wonCoupons = coupons.filter((c) => c.status === 'WON');
const totalInvested = totalCoupons; // Her kupona 1 birim yatırıldığını varsayıyoruz
const totalReturn = wonCoupons.reduce((acc, c) => acc + c.totalOdds, 0);
const winRate = (wonCoupons.length / totalCoupons) * 100;
const roi = ((totalReturn - totalInvested) / totalInvested) * 100;
return {
totalCoupons,
wonCoupons: wonCoupons.length,
winRate: parseFloat(winRate.toFixed(2)),
totalInvested,
totalReturn: parseFloat(totalReturn.toFixed(2)),
roi: parseFloat(roi.toFixed(2)),
};
}
}