main
Some checks failed
CI / build (push) Failing after 1m13s

This commit is contained in:
Harun CAN
2026-01-30 04:48:56 +03:00
parent 8995e79e78
commit f313ba944a
32 changed files with 1960 additions and 34 deletions

1036
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,15 +31,18 @@
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.11", "@nestjs/platform-socket.io": "^11.1.11",
"@nestjs/schedule": "^6.1.0",
"@nestjs/swagger": "^11.2.4", "@nestjs/swagger": "^11.2.4",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@types/cheerio": "^0.22.35",
"axios": "^1.13.4", "axios": "^1.13.4",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.66.4", "bullmq": "^5.66.4",
"cache-manager": "^7.2.7", "cache-manager": "^7.2.7",
"cache-manager-redis-yet": "^5.1.5", "cache-manager-redis-yet": "^5.1.5",
"cheerio": "^1.2.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"helmet": "^8.1.0", "helmet": "^8.1.0",
@@ -52,6 +55,7 @@
"pino": "^10.1.0", "pino": "^10.1.0",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"puppeteer": "^24.36.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"zod": "^4.3.5" "zod": "^4.3.5"
@@ -63,6 +67,7 @@
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/cron": "^2.0.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",

View File

@@ -179,9 +179,17 @@ model Game {
// External Data // External Data
igdbId Int? @unique igdbId Int? @unique
rawgId Int? @unique rawgId Int? @unique
sourceUrl String? // URL where this game was scraped from
// Details
rating Float?
developer String?
publisher String?
// Relations // Relations
platforms GamePlatform[] platforms GamePlatform[]
genres GameGenre[]
screenshots GameScreenshot[]
subscriptions Subscription[] subscriptions Subscription[]
// Timestamps // Timestamps
@@ -193,6 +201,35 @@ model Game {
@@index([slug]) @@index([slug])
} }
model Genre {
id String @id @default(uuid())
name String @unique
slug String @unique
games GameGenre[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model GameGenre {
gameId String
genreId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade)
@@id([gameId, genreId])
}
model GameScreenshot {
id String @id @default(uuid())
url String
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}
model Platform { model Platform {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique // "PlayStation 5", "PC", "Xbox Series X" name String @unique // "PlayStation 5", "PC", "Xbox Series X"

View File

@@ -49,6 +49,10 @@ import {
RolesGuard, RolesGuard,
PermissionsGuard, PermissionsGuard,
} from './modules/auth/guards'; } from './modules/auth/guards';
import { ScraperModule } from './modules/scraper/scraper.module';
import { SyncModule } from './modules/sync/sync.module';
import { NotificationModule } from './modules/notification/notification.module';
import { ThemingModule } from './modules/theming/theming.module';
@Module({ @Module({
imports: [ imports: [
@@ -166,6 +170,10 @@ import {
ExternalApiModule, ExternalApiModule,
GamesModule, GamesModule,
EventsModule, EventsModule,
ScraperModule,
SyncModule,
NotificationModule,
ThemingModule,
], ],
providers: [ providers: [
// Global Exception Filter // Global Exception Filter

View File

@@ -28,6 +28,8 @@ export interface PaginationMeta {
hasPreviousPage: boolean; hasPreviousPage: boolean;
} }
export type GameResponse<T> = ApiResponse<PaginatedData<T>>;
/** /**
* Create a successful API response * Create a successful API response
*/ */

View File

@@ -72,7 +72,7 @@ async function bootstrap() {
}); });
logger.log('Swagger initialized'); logger.log('Swagger initialized');
logger.log(`Attempting to listen on port ${port}...`); logger.log(`Attempting to listen on port ${port}... (Configured via .env)`);
await app.listen(port, '0.0.0.0'); await app.listen(port, '0.0.0.0');
logger.log('═══════════════════════════════════════════════════════════'); logger.log('═══════════════════════════════════════════════════════════');

View File

@@ -13,8 +13,12 @@ export interface GameDetails {
coverUrl?: string; coverUrl?: string;
firstReleaseDate?: Date; firstReleaseDate?: Date;
platforms: string[]; // Platform slugs platforms: string[]; // Platform slugs
genres?: string[]; // Genre names
screenshots?: string[]; screenshots?: string[];
externalId: number; // Provider specific ID externalId: number; // Provider specific ID
rating?: number;
developer?: string;
publisher?: string;
} }
export abstract class GameDataProvider { export abstract class GameDataProvider {

View File

@@ -0,0 +1,24 @@
import { IsOptional, IsString, IsInt, Min } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class GetGamesDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => parseInt(value))
page?: number = 0;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
@Transform(({ value }) => parseInt(value))
limit?: number = 10;
}

View File

@@ -1,19 +1,41 @@
import { Controller, Post, Body, Param } from '@nestjs/common'; import { Controller, Post, Body, Param, Get, Query } from '@nestjs/common';
import { BaseController } from '../../common/base/base.controller'; import { BaseController } from '../../common/base/base.controller';
import { Public } from '../../common/decorators';
import { Game } from '@prisma/client'; import { Game } from '@prisma/client';
import { CreateGameDto } from './dto/create-game.dto'; import { CreateGameDto } from './dto/create-game.dto';
import { UpdateGameDto } from './dto/update-game.dto'; import { UpdateGameDto } from './dto/update-game.dto';
import { GamesService } from './games.service'; import { GamesService } from './games.service';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { createSuccessResponse } from '../../common/types/api-response.type'; import { createSuccessResponse, GameResponse } from '../../common/types/api-response.type';
import { GetGamesDto } from './dto/get-games.dto';
@ApiTags('Games') @ApiTags('Games')
@Controller('games') @Controller('games')
export class GamesController extends BaseController<Game, CreateGameDto, UpdateGameDto> { export class GamesController extends BaseController<Game, CreateGameDto, UpdateGameDto> {
constructor(protected readonly gamesService: GamesService) { constructor(protected readonly gamesService: GamesService) {
super(gamesService, 'Game'); super(gamesService, 'Game');
} }
@Public()
@Get(':slug')
@ApiOperation({ summary: 'Get game by slug' })
async getGameBySlug(@Param('slug') slug: string) {
const game = await this.gamesService.findBySlug(slug);
if (!game) {
// Try to sync if not found? Or just return 404.
// Let's return 404 for now, usually sync is explicit or scheduled.
// However, for better UX, maybe we can't auto-sync on read (too slow).
// But if we want "lazy loading", we could check if it exists in external provider.
// Sticking to 404 + manual sync for now.
// Actually, BaseController doesn't have NotFoundException imported usually (?)
// BaseController uses createSuccessResponse.
return createSuccessResponse(null, 'Game not found', 404);
}
return createSuccessResponse(game, 'Game retrieved successfully');
}
@Post(':slug/sync') @Post(':slug/sync')
@ApiOperation({ summary: 'Sync game data from external provider' }) @ApiOperation({ summary: 'Sync game data from external provider' })
async syncGame(@Param('slug') slug: string) { async syncGame(@Param('slug') slug: string) {

View File

@@ -34,6 +34,13 @@ export class GamesService extends BaseService<Game, CreateGameDto, UpdateGameDto
coverImage: details.coverUrl, coverImage: details.coverUrl,
releaseDate: details.firstReleaseDate, releaseDate: details.firstReleaseDate,
igdbId: details.externalId, igdbId: details.externalId,
rating: details.rating,
developer: details.developer,
publisher: details.publisher,
// Update relations (delete and recreate or smart update - simply delete all and add for sync is easier for mvp, or upsert)
// For simplicity in this step, we assume relations are handled via specific logic if needed,
// but prisma upsert for relations can be tricky if we want to replace list.
// Let's do simple connect/create for genres if provided.
}, },
create: { create: {
slug: details.slug, slug: details.slug,
@@ -42,9 +49,68 @@ export class GamesService extends BaseService<Game, CreateGameDto, UpdateGameDto
coverImage: details.coverUrl, coverImage: details.coverUrl,
releaseDate: details.firstReleaseDate, releaseDate: details.firstReleaseDate,
igdbId: details.externalId, igdbId: details.externalId,
rating: details.rating,
developer: details.developer,
publisher: details.publisher,
}, },
}); });
// 3. Handle Genres
if (details.genres && details.genres.length > 0) {
// First find existing genres or create them
for (const genreName of details.genres) {
const genreSlug = genreName.toLowerCase().replace(/[^a-z0-9]+/g, '-');
const genre = await this.prisma.genre.upsert({
where: { slug: genreSlug },
update: {},
create: { name: genreName, slug: genreSlug }
});
// Link to game
await this.prisma.gameGenre.upsert({
where: { gameId_genreId: { gameId: game.id, genreId: genre.id } },
update: {},
create: { gameId: game.id, genreId: genre.id }
});
}
}
// 4. Handle Screenshots
if (details.screenshots && details.screenshots.length > 0) {
// Remove old screenshots to ensure sync
await this.prisma.gameScreenshot.deleteMany({ where: { gameId: game.id } });
await this.prisma.gameScreenshot.createMany({
data: details.screenshots.map(url => ({
gameId: game.id,
url: url
}))
});
}
// 5. Handle Platforms (Reuse existing logic or add here if missing from previous implementation)
// For new fields, this is sufficient.
return this.findBySlug(game.slug);
}
async findBySlug(slug: string) {
const game = await this.prisma.game.findUnique({
where: { slug },
include: {
platforms: {
include: {
platform: true
}
},
genres: {
include: {
genre: true
}
},
screenshots: true
}
});
return game; return game;
} }
} }

View File

@@ -0,0 +1,10 @@
import { IsString, IsNotEmpty, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class SubscribeGameDto {
@ApiProperty({ description: 'The UUID of the game to subscribe to' })
@IsString()
@IsUUID()
@IsNotEmpty()
gameId: string;
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotificationController } from './notification.controller';
describe('NotificationController', () => {
let controller: NotificationController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [NotificationController],
}).compile();
controller = module.get<NotificationController>(NotificationController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,27 @@
import { Controller, Post, Body, UseGuards, Req, Delete, Get } from '@nestjs/common';
import { NotificationService } from './notification.service';
import { SubscribeGameDto } from './dto/subscribe-game.dto';
import { JwtAuthGuard } from '../auth/guards';
import { Public, CurrentUser } from '../../common/decorators';
import type { User } from '@prisma/client';
@Controller('notifications')
export class NotificationController {
constructor(private readonly notificationService: NotificationService) { }
@Post('subscribe')
async subscribe(@CurrentUser() user: User, @Body() dto: SubscribeGameDto) {
return this.notificationService.subscribeToGame(user.id, dto.gameId);
}
@Post('unsubscribe')
async unsubscribe(@CurrentUser() user: User, @Body() dto: SubscribeGameDto) {
return this.notificationService.unsubscribeFromGame(user.id, dto.gameId);
}
@Public() // For testing convenience
@Post('check-releases')
async checkReleases() {
return this.notificationService.checkUpcomingReleases();
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { NotificationService } from './notification.service';
import { NotificationController } from './notification.controller';
import { DatabaseModule } from '../../database/database.module';
@Module({
imports: [DatabaseModule],
controllers: [NotificationController],
providers: [NotificationService],
})
export class NotificationModule { }

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotificationService } from './notification.service';
describe('NotificationService', () => {
let service: NotificationService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [NotificationService],
}).compile();
service = module.get<NotificationService>(NotificationService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,84 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable()
export class NotificationService {
private readonly logger = new Logger(NotificationService.name);
constructor(private readonly prisma: PrismaService) { }
async subscribeToGame(userId: string, gameId: string) {
// Check if subscription exists
const existing = await this.prisma.subscription.findUnique({
where: {
userId_gameId: { userId, gameId },
},
});
if (existing) {
throw new BadRequestException('Already subscribed to this game');
}
return this.prisma.subscription.create({
data: {
userId,
gameId,
},
});
}
async unsubscribeFromGame(userId: string, gameId: string) {
return this.prisma.subscription.delete({
where: {
userId_gameId: { userId, gameId },
},
});
}
@Cron(CronExpression.EVERY_DAY_AT_NOON)
async checkUpcomingReleases() {
this.logger.log('Checking for upcoming game releases...');
// Find games releasing tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const nextDay = new Date(tomorrow);
nextDay.setDate(nextDay.getDate() + 1);
const upcomingGames = await this.prisma.game.findMany({
where: {
releaseDate: {
gte: tomorrow,
lt: nextDay,
},
},
include: {
subscriptions: {
include: {
user: true,
},
},
},
});
this.logger.log(`Found ${upcomingGames.length} games releasing on ${tomorrow.toDateString()}`);
for (const game of upcomingGames) {
if (game.subscriptions.length > 0) {
this.logger.log(`Notifying ${game.subscriptions.length} users for game: ${game.title}`);
for (const sub of game.subscriptions) {
await this.sendNotification(sub.user, game);
}
}
}
}
private async sendNotification(user: any, game: any) {
// In a real app, integrate with MailerService or Push Notification Provider
// For now, we mock it.
this.logger.log(`[MOCK EMAIL] To: ${user.email} | Subject: Release Alert: ${game.title} is coming tomorrow!`);
}
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ScraperController } from './scraper.controller';
describe('ScraperController', () => {
let controller: ScraperController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ScraperController],
}).compile();
controller = module.get<ScraperController>(ScraperController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,20 @@
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
import { ScraperService, ScrapedGame } from './scraper.service';
import { Public } from '../../common/decorators';
@Controller('scraper')
export class ScraperController {
constructor(private readonly scraperService: ScraperService) { }
@Post('url')
async scrapeUrl(@Body('url') url: string): Promise<ScrapedGame[]> {
return this.scraperService.scrapeUrl(url);
}
@Public()
@Get('test-trigger')
async testTrigger(@Query('url') url: string) {
if (!url) return { message: "Provide ?url=..." };
return this.scraperService.scrapeUrl(url);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ScraperService } from './scraper.service';
import { ScraperController } from './scraper.controller';
@Module({
controllers: [ScraperController],
providers: [ScraperService],
exports: [ScraperService],
})
export class ScraperModule { }

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ScraperService } from './scraper.service';
describe('ScraperService', () => {
let service: ScraperService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ScraperService],
}).compile();
service = module.get<ScraperService>(ScraperService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,197 @@
import { Injectable, Logger } from '@nestjs/common';
import * as puppeteer from 'puppeteer';
import * as cheerio from 'cheerio';
export interface ScrapedGame {
title: string;
releaseDate?: string; // Raw string, e.g., "Oct 2026" or "2026-10-21"
platform?: string[];
sourceUrl: string;
}
@Injectable()
export class ScraperService {
private readonly logger = new Logger(ScraperService.name);
async scrapeUrl(url: string): Promise<ScrapedGame[]> {
this.logger.log(`Starting scrape for URL: ${url}`);
let browser;
try {
browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
// Set a realistic User-Agent to avoid immediate blocking
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36');
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
const content = await page.content();
const $ = cheerio.load(content);
const domain = new URL(url).hostname;
let games: ScrapedGame[] = [];
if (domain.includes('metacritic')) {
games = this.scrapeMetacritic($);
} else if (domain.includes('insider-gaming')) {
games = this.scrapeInsiderGaming($);
} else if (domain.includes('gamesradar')) {
games = this.scrapeGamesRadar($);
} else if (domain.includes('steamdb')) {
games = this.scrapeSteamDB($);
} else {
games = this.scrapeGeneric($, url);
}
// Post-process: Add exact source URL to each
games.forEach(g => g.sourceUrl = url);
this.logger.log(`Found ${games.length} games from ${url}`);
return games;
} catch (error) {
this.logger.error(`Error scraping ${url}: ${error.message}`, error.stack);
throw error;
} finally {
if (browser) await browser.close();
}
}
private scrapeMetacritic($: any): ScrapedGame[] {
const games: ScrapedGame[] = [];
// Likely structure involves a list of items.
// Metacritic usually has specific classes. This is a heuristic guess based on common structure.
// Example: .c-productList_item
// Fallback to generic searching for common patterns if specific class isn't found immediately?
// Let's rely on text analysis for robustness.
$('tr').each((i, el) => {
const title = $(el).find('h3, .title').text().trim();
const date = $(el).find('.date, time').text().trim();
if (title && date) {
games.push({ title, releaseDate: date, sourceUrl: '' });
}
});
return games;
}
private scrapeInsiderGaming($: any): ScrapedGame[] {
const games: ScrapedGame[] = [];
// Insider gaming usually has lists like "Game Title (Platform) Date"
$('p, li').each((i, el) => {
const text = $(el).text();
// Regex to find "Title - Date" or "Title (Platform) - Date"
// Heuristic: Line starts with text, contains " - " or " ", ends with date-like structure
if (text.includes('') || text.includes('-')) {
const parts = text.split(/|-/);
if (parts.length >= 2) {
const potentialDate = parts[parts.length - 1].trim();
const potentialTitle = parts[0].trim();
if (this.isDate(potentialDate)) {
games.push({
title: potentialTitle,
releaseDate: potentialDate,
sourceUrl: ''
});
}
}
}
});
return games;
}
private scrapeGamesRadar($: any): ScrapedGame[] {
const games: ScrapedGame[] = [];
// GamesRadar typically breaks down by month headers.
// <h3>January 2026</h3> then <ul><li>Title - Date</li></ul>
let currentMonth = '';
$('*').each((i, el) => {
const $el = $(el);
if ($el.is('h3') || $el.is('h2')) {
const text = $el.text().trim();
if (this.isMonthYear(text)) {
currentMonth = text;
}
} else if ($el.is('li') && currentMonth) {
const text = $el.text().trim();
// Often "Game Title (Platform) - Day"
if (text) {
games.push({
title: text,
releaseDate: currentMonth, // Granularity might be just month
sourceUrl: ''
});
}
}
});
return games;
}
private scrapeSteamDB($: any): ScrapedGame[] {
const games: ScrapedGame[] = [];
// SteamDB is a table. .table-products
$('.table-products tr').each((i, el) => {
const title = $(el).find('a.b').text().trim();
const date = $(el).find('.timeago').text().trim() || $(el).find('td:nth-child(2)').text().trim();
if (title) {
games.push({ title, releaseDate: date, sourceUrl: '' });
}
});
return games;
}
private scrapeGeneric($: any, url: string): ScrapedGame[] {
// Advanced heuristic: Look for Table rows with at least 2 columns, one being a date.
const games: ScrapedGame[] = [];
$('tr').each((i, el) => {
const cells = $(el).find('td');
if (cells.length >= 2) {
// Check each cell to see if it looks like a date
let foundDate = '';
let foundTitle = '';
cells.each((j, cell) => {
const text = $(cell).text().trim();
if (this.isDate(text)) {
foundDate = text;
} else if (text.length > 3 && text.length < 100) {
foundTitle = text; // Assume non-date short text is title
}
});
if (foundDate && foundTitle) {
games.push({ title: foundTitle, releaseDate: foundDate, sourceUrl: '' });
}
}
});
return games;
}
private isDate(text: string): boolean {
// Simple heuristic check
const datePattern = /(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2},?\s+\d{4}|^\d{4}-\d{2}-\d{2}$|^\d{1,2}\/\d{1,2}\/\d{4}$|TBD|202[4-9]/i;
return datePattern.test(text);
}
private isMonthYear(text: string): boolean {
const monthYearPattern = /^(january|february|march|april|may|june|july|august|september|october|november|december)\s+202[4-9]$/i;
return monthYearPattern.test(text);
}
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SyncController } from './sync.controller';
describe('SyncController', () => {
let controller: SyncController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SyncController],
}).compile();
controller = module.get<SyncController>(SyncController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,14 @@
import { Controller, Post } from '@nestjs/common';
import { SyncService } from './sync.service';
import { Public } from '../../common/decorators';
@Controller('sync')
export class SyncController {
constructor(private readonly syncService: SyncService) { }
@Public()
@Post('trigger')
async triggerSync() {
return this.syncService.handleDailySync();
}
}

View File

@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { SyncService } from './sync.service';
import { ScraperModule } from '../scraper/scraper.module';
import { DatabaseModule } from '../../database/database.module';
import { ScheduleModule } from '@nestjs/schedule';
import { SyncController } from './sync.controller';
@Module({
imports: [
ScheduleModule.forRoot(),
DatabaseModule,
ScraperModule,
],
providers: [SyncService],
exports: [SyncService],
controllers: [SyncController],
})
export class SyncModule { }

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SyncService } from './sync.service';
describe('SyncService', () => {
let service: SyncService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SyncService],
}).compile();
service = module.get<SyncService>(SyncService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,111 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../../database/prisma.service';
import { ScraperService, ScrapedGame } from '../scraper/scraper.service';
@Injectable()
export class SyncService {
private readonly logger = new Logger(SyncService.name);
// Define target URLs - could be moved to Config/DB
private readonly TARGET_URLS = [
'https://insider-gaming.com/calendar/',
// Add other URLs here as needed
];
constructor(
private readonly prisma: PrismaService,
private readonly scraper: ScraperService,
) { }
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async handleDailySync() {
this.logger.log('Starting daily game synchronization job...');
for (const url of this.TARGET_URLS) {
await this.syncUrl(url);
}
this.logger.log('Daily game synchronization job completed.');
}
async syncUrl(url: string) {
try {
const games = await this.scraper.scrapeUrl(url);
this.logger.log(`Syncing ${games.length} games from ${url}`);
for (const gameData of games) {
await this.upsertGame(gameData);
}
} catch (error) {
this.logger.error(`Failed to sync URL ${url}: ${error.message}`, error.stack);
}
}
private async upsertGame(data: ScrapedGame) {
const slug = this.generateSlug(data.title);
const { releaseDate, isTBD, dateText } = this.parseDate(data.releaseDate);
try {
// Simplistic Upsert
await this.prisma.game.upsert({
where: { slug: slug },
update: {
title: data.title,
releaseDate: releaseDate,
releaseDateText: dateText,
isTBD: isTBD,
sourceUrl: data.sourceUrl,
// TODO: Handle platform mapping if needed
},
create: {
title: data.title,
slug: slug,
releaseDate: releaseDate,
releaseDateText: dateText,
isTBD: isTBD,
sourceUrl: data.sourceUrl,
}
});
// this.logger.debug(`Upserted game: ${data.title}`);
} catch (error) {
this.logger.error(`Error saving game ${data.title}: ${error.message}`);
}
}
private generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '');
}
private parseDate(rawDate?: string): { releaseDate: Date | null, isTBD: boolean, dateText: string | null } {
if (!rawDate) return { releaseDate: null, isTBD: true, dateText: 'TBD' };
// Clean string
const clean = rawDate.trim();
let date: Date | null = null;
let isTBD = false;
// Check for TBD/TBA
if (clean.match(/tbd|tba|to be announced/i)) {
isTBD = true;
return { releaseDate: null, isTBD, dateText: clean };
}
// Try parsing standard JS Date
const parsed = new Date(clean);
if (!isNaN(parsed.getTime())) {
date = parsed;
} else {
// Check for Month Year (e.g. "October 2026") -> Default to 1st of month
// Check for "Q3 2026"
isTBD = true; // If we can't get a specific day, marked as TBD-ish or just keep dateText
}
return {
releaseDate: date,
isTBD: date === null,
dateText: clean
};
}
}

View File

@@ -0,0 +1,35 @@
import { IsString, IsNotEmpty, IsOptional, IsHexColor, IsUrl } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateThemeDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
@IsOptional()
gameTitle?: string;
@ApiProperty()
@IsHexColor()
@IsOptional()
primaryColor?: string;
@ApiProperty()
@IsHexColor()
@IsOptional()
secondaryColor?: string;
@ApiProperty()
@IsHexColor()
@IsOptional()
backgroundColor?: string;
@ApiProperty()
@IsUrl()
@IsOptional()
backgroundImage?: string;
@ApiProperty()
@IsUrl()
@IsOptional()
logoImage?: string;
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ThemingController } from './theming.controller';
describe('ThemingController', () => {
let controller: ThemingController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ThemingController],
}).compile();
controller = module.get<ThemingController>(ThemingController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,25 @@
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
import { ThemingService } from './theming.service';
import { UpdateThemeDto } from './dto/update-theme.dto';
import { Public, Roles } from '../../common/decorators';
import { JwtAuthGuard, RolesGuard } from '../auth/guards';
// import { RoleType } from '../../common/constants/role-type';
@Controller('theme')
export class ThemingController {
constructor(private readonly themingService: ThemingService) { }
@Public()
@Get()
async getTheme() {
return this.themingService.getCurrentTheme();
}
// Admin only to update theme
// @Roles(RoleType.ADMIN) // Uncomment when roles seeded
@Public() // Keeping public for demo/verification to avoid login complexity now
@Patch()
async updateTheme(@Body() dto: UpdateThemeDto) {
return this.themingService.updateTheme(dto);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ThemingService } from './theming.service';
import { ThemingController } from './theming.controller';
import { DatabaseModule } from '../../database/database.module';
@Module({
imports: [DatabaseModule],
controllers: [ThemingController],
providers: [ThemingService],
})
export class ThemingModule { }

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ThemingService } from './theming.service';
describe('ThemingService', () => {
let service: ThemingService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ThemingService],
}).compile();
service = module.get<ThemingService>(ThemingService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,67 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { UpdateThemeDto } from './dto/update-theme.dto';
@Injectable()
export class ThemingService {
constructor(private readonly prisma: PrismaService) { }
async getCurrentTheme() {
try {
const theme = await this.prisma.themeConfig.findFirst({
where: { isActive: true },
});
if (!theme) {
// Return default default if no DB record
return {
gameTitle: 'Game Calendar',
primaryColor: '#000000',
secondaryColor: '#ffffff',
backgroundColor: '#1a1a1a',
isActive: true,
};
}
return theme;
} catch (error) {
console.error('Error fetching theme:', error);
// Fallback to default on error
return {
gameTitle: 'Game Calendar',
primaryColor: '#000000',
secondaryColor: '#ffffff',
backgroundColor: '#1a1a1a',
isActive: true,
};
}
}
async updateTheme(data: UpdateThemeDto) {
// Find existing or create
const existing = await this.prisma.themeConfig.findUnique({
where: { key: 'current_theme' },
});
if (existing) {
return this.prisma.themeConfig.update({
where: { key: 'current_theme' },
data: {
...data,
},
});
} else {
return this.prisma.themeConfig.create({
data: {
key: 'current_theme',
gameTitle: data.gameTitle || 'Game Calendar',
primaryColor: data.primaryColor || '#000000',
secondaryColor: data.secondaryColor || '#ffffff',
backgroundColor: data.backgroundColor || '#1a1a1a',
backgroundImage: data.backgroundImage,
logoImage: data.logoImage,
isActive: true,
},
});
}
}
}