1036
package-lock.json
generated
1036
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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('═══════════════════════════════════════════════════════════');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
24
src/modules/games/dto/get-games.dto.ts
Normal file
24
src/modules/games/dto/get-games.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/modules/notification/dto/subscribe-game.dto.ts
Normal file
10
src/modules/notification/dto/subscribe-game.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
18
src/modules/notification/notification.controller.spec.ts
Normal file
18
src/modules/notification/notification.controller.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
27
src/modules/notification/notification.controller.ts
Normal file
27
src/modules/notification/notification.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/modules/notification/notification.module.ts
Normal file
11
src/modules/notification/notification.module.ts
Normal 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 { }
|
||||||
18
src/modules/notification/notification.service.spec.ts
Normal file
18
src/modules/notification/notification.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
84
src/modules/notification/notification.service.ts
Normal file
84
src/modules/notification/notification.service.ts
Normal 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!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/modules/scraper/scraper.controller.spec.ts
Normal file
18
src/modules/scraper/scraper.controller.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
src/modules/scraper/scraper.controller.ts
Normal file
20
src/modules/scraper/scraper.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/modules/scraper/scraper.module.ts
Normal file
10
src/modules/scraper/scraper.module.ts
Normal 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 { }
|
||||||
18
src/modules/scraper/scraper.service.spec.ts
Normal file
18
src/modules/scraper/scraper.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
197
src/modules/scraper/scraper.service.ts
Normal file
197
src/modules/scraper/scraper.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/modules/sync/sync.controller.spec.ts
Normal file
18
src/modules/sync/sync.controller.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/modules/sync/sync.controller.ts
Normal file
14
src/modules/sync/sync.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/modules/sync/sync.module.ts
Normal file
18
src/modules/sync/sync.module.ts
Normal 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 { }
|
||||||
18
src/modules/sync/sync.service.spec.ts
Normal file
18
src/modules/sync/sync.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
111
src/modules/sync/sync.service.ts
Normal file
111
src/modules/sync/sync.service.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/modules/theming/dto/update-theme.dto.ts
Normal file
35
src/modules/theming/dto/update-theme.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
18
src/modules/theming/theming.controller.spec.ts
Normal file
18
src/modules/theming/theming.controller.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
25
src/modules/theming/theming.controller.ts
Normal file
25
src/modules/theming/theming.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/modules/theming/theming.module.ts
Normal file
11
src/modules/theming/theming.module.ts
Normal 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 { }
|
||||||
18
src/modules/theming/theming.service.spec.ts
Normal file
18
src/modules/theming/theming.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
src/modules/theming/theming.service.ts
Normal file
67
src/modules/theming/theming.service.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user