Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ src/
9. Use debouncing for search inputs (use-debounce)
10. Handle loading and error states consistently
11. **Native UI Development**: When the "use-gluestack-components" MCP server is connected and you're working on React Native UI, always consult it first for component selection and implementation patterns. Prefer Gluestack UI components for accessibility and cross-platform consistency.
12. **NEVER use barrel exports** (index.ts/index.js files that re-export from other files). Always import directly from the source file (e.g., `import { Box } from "../layout/box/box"` instead of `import { Box } from "../layout/box"`). Barrel exports cause performance issues, circular dependency problems, and make it harder to track imports.

## Useful Commands
```bash
Expand Down Expand Up @@ -393,6 +394,7 @@ yarn build-android # EAS build Android
- **API**: Uses tsconfig paths (e.g., `@/modules/...`)
- **Web**: `@/` → `src/`
- **Native**: Relative imports for modules, absolute for ui
- **Important**: Always import directly from source files, NOT from index.ts barrel exports (e.g., use `from "./box/box"` not `from "./box"`)

## Dependencies Management
- Use exact versions for critical dependencies
Expand Down
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@nestjs/apollo": "^12.0.11",
"@nestjs/axios": "^3.0.1",
"@nestjs/bull": "^10.0.1",
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/cqrs": "11.0.2",
Expand All @@ -43,6 +44,7 @@
"@types/user-agents": "^1.0.4",
"axios": "^1.6.2",
"bull": "^4.11.5",
"cache-manager": "^7.2.8",
"cheerio": "^1.0.0-rc.12",
"cloudinary": "^1.41.2",
"dayjs": "^1.11.10",
Expand Down
74 changes: 74 additions & 0 deletions apps/api/plan-cqrsHexagonalRefactor.prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
## Plan: Refaktoryzacja API pod CQRS i Architekturę Heksagonalną

Ten plan ma na celu przekształcenie obecnej architektury "N-Tier" w pełnoprawną Architekturę Heksagonalną (Ports & Adapters) z CQRS, aby odseparować logikę biznesową od frameworka i bazy danych.

### Główne Założenia
* **Domain Layer (Jądro)**: Czyste klasy TypeScript (Encje, Value Objects). Zero zależności od NestJS, Prisma czy zewnętrznych bibliotek.
* **Application Layer**: Obsługa Use Cases (CQRS Handlers). Definiuje interfejsy (Porty) dla komunikacji ze światem zewnętrznym.
* **Infrastructure Layer**: Implementacje interfejsów (Adaptery). Tu żyje Prisma, Resolvery GraphQL, Kontrolery REST i konfiguracja modułów NestJS.

### Struktura folderów (Nowy schemat)
Dla każdego modułu (np. `games`, `users`) zastosujemy strukturę:
```
modules/<feature>/
├── domain/ # WARSTWA DOMENY
│ ├── models/ # Encje (np. Game, User) i Value Objects
│ └── ports/ # Interfejsy repozytoriów i serwisów zewnętrznych
├── application/ # WARSTWA APLIKACJI
│ ├── commands/ # Komendy i ich Handlery (WRITE model)
│ └── queries/ # Zapytania i ich Handlery (READ model)
└── infrastructure/ # WARSTWA INFRASTRUKTURY
├── adapters/ # Implementacje portów (np. PrismaGameRepository)
├── graphql/ # Resolvery i DTO (Inputs/Args)
└── <feature>.module.ts # Konfiguracja modułu (DI)
```

### Steps

#### Faza 1: Przygotowanie Fundamentów
1. **Utwórz współdzielone abstrakcje w `libs/ddd` (lub `shared`)**
* Stwórz klasy bazowe `AggregateRoot` i `Entity` dla domeny.
* Zdefiniuj typy generyczne dla `RepositoryPort`.

#### Faza 2: Migracja Modułu `Games` (Jako Pilot)
To najbardziej złożony moduł, idealny do przetestowania wzorca.

1. **Wyodrębnij Domenę (`domain/`)**
* Stwórz encję `Game` w `domain/models/game.model.ts`. Przenieś tam logikę biznesową (np. walidacje stanu, metody operujące na danych), która obecnie jest rozproszona w serwisach.
* Zdefiniuj interfejs `GameRepositoryPort` w `domain/ports/`. Powinien operować na encjach domeny, nie na typach Prisma.

2. **Przenieś Warstwę Aplikacji (`application/`)**
* Przenieś obecne `commands/` i `queries/` do folderu `application/`.
* W `UpdateGameDataHandler`:
* Zamiast `PrismaService`, wstrzyknij `GameRepositoryPort`.
* Zamiast bezpośrednich operacji na DB: pobierz encję -> wykonaj metodę na encji -> zapisz encję.

3. **Zbuduj Warstwę Infrastruktury (`infrastructure/`)**
* Stwórz `PrismaGameRepository` w `adapters/`, który implementuje `GameRepositoryPort`.
* To tutaj przenieś logikę mapowania z obiektów Prisma na Encje Domenowe (Mappery).
* Przenieś `GamesResolver` do `infrastructure/graphql/`.

4. **Skonfiguruj Dependency Injection**
* W `GamesModule` zarejestruj implementację portu: `provide: GameRepositoryPort, useClass: PrismaGameRepository`.

#### Faza 3: Migracja Modułu `Users` i `Profiles`
Powtórz proces dla zarządzania użytkownikami, co uporządkuje zależności Auth0 i seedowania.

1. **Separacja Modeli**
* Stwórz domenowy model `User`, który posiada metody biznesowe (np. `updateProfile`, `addFriend`).
* Oddziel model bazy danych (Prisma User) od modelu domeny.

2. **Adaptery Zewnętrzne**
* Dla integracji z Auth0 (User Management), stwórz port `IdentityServicePort` w domenie i adapter w infrastrukturze.

#### Faza 4: CQRS - Rozdzielenie Modelu Odczytu (Read Model)
Dla zapytań (Queries), pełna heksagonalna "czystość" bywa uciążliwa (overhead przy mapowaniu).

1. **Optymalizacja Queries**
* Zezwól Handlerom Query (`application/queries/`) na bezpośredni dostęp do `PrismaService` lub dedykowanych widoków (Raw SQL), pomijając Encje Domenowe na rzecz szybkich DTO (Read Models).
* Dla Command (zapis) **zawsze** używaj Repozytoriów i Encji.

### Further Considerations
1. **Mappery**: Będziesz potrzebował mapperów (np. `GameMapper`), które konwertują `PrismaGame` <-> `DomainGame`. To wymaga napisania dodatkowego kodu, ale daje niezależność.
2. **Komunikacja między modułami**: Moduł `Games` nie powinien importować `UsersService`. Zamiast tego `Games` powinien zdefiniować port `OwnerServicePort`, a moduł `Users` powinien go zaimplementować (lub użyć QueryBus/CommandBus do komunikacji).
3. **Transakcje**: W architekturze heksagonalnej transakcje są trudniejsze. Rozważ użycie `UnitOfWork` lub mechanizmu transakcji NestJS (`ClsService`) w warstwie aplikacji.
132 changes: 132 additions & 0 deletions apps/api/src/infrastructure/igdb/igdb.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { ExternalGameDTO } from '../../modules/games/games.dto';
import { firstValueFrom } from 'rxjs';
import { AxiosResponse, isAxiosError } from 'axios';
import { IGamesProvider } from '../../modules/games/interfaces/games-provider.interface';
import { OAuthTokenDto } from './igdb.dto';
import { timestampToMs } from '../../modules/date_and_time/time/timestamp_to_ms';

@Injectable()
export class IgdbService implements IGamesProvider {
private readonly logger = new Logger(IgdbService.name);

constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}

private accessToken: string | null = null;
private tokenExpiration: number = 0;

async getUpcomingGames(limit: number): Promise<ExternalGameDTO[]> {
const now = Math.floor(Date.now() / 1000);

const query = `
fields name, cover.url, screenshots.url, first_release_date, platforms.name, category;

where
first_release_date > ${now} &
platforms = (48,167,49,169,130) &
cover != null &
game_type = (0, 2, 4, 8, 9) &
version_parent = null;

sort first_release_date asc;
limit ${limit};
`;

const token = await this.getTokenFromOAuth();

try {
const { data } = await firstValueFrom(
this.httpService.post('https://api.igdb.com/v4/games', query, {
headers: {
'Client-ID': this.configService.get('IGDB_CLIENT_ID'),
Authorization: `Bearer ${token}`,
},
}),
);
return data.map(this.mapToDto);
} catch (e) {
this.logger.error('Error fetching games from IGDB', e);
return [];
}
}

async getTokenFromOAuth(): Promise<string> {
const now = Date.now();

if (this.accessToken && this.tokenExpiration > now + 3600 * 1000) {
return this.accessToken;
}
this.logger.log('Refreshing IGDB Access Token...');
try {
const { data } = await firstValueFrom<AxiosResponse<OAuthTokenDto>>(
this.httpService.post('https://id.twitch.tv/oauth2/token', null, {
params: {
client_id: this.configService.get('IGDB_CLIENT_ID'),
client_secret: this.configService.get('IGDB_CLIENT_SECRET'),
grant_type: 'client_credentials',
},
}),
);
this.accessToken = data.access_token;
this.tokenExpiration = now + timestampToMs(data.expires_in);
return this.accessToken;
} catch (e) {
if (isAxiosError(e)) {
this.logger.error(
'Failed to authenticate with Twitch/IGDB',
e.response?.data || e.message,
);
}
throw e;
}
}

private mapToDto(game: IgdbGame): ExternalGameDTO {
return {
id: game.id.toString(),
name: game.name,
coverUrl: game.cover?.url
? `https:${game.cover.url.replace('t_thumb', 't_cover_big')}`
: '',
backgroundUrl: game.screenshots?.[0]?.url
? `https:${game.screenshots[0].url.replace(
't_thumb',
't_screenshot_big',
)}`
: '',
releaseDate: new Date(game.first_release_date * 1000),
platforms:
game.platforms?.map((p: any) => ({
id: p.id.toString(),
name: p.name,
})) || [],
};
}
}

type IgdbGame = {
id: string;
cover: {
id: string;
url: string;
};
first_release_date: number;
name: string;
platforms: IgdbGamePlatform[];
screenshots: IgdbGameScreenshots[];
};

type IgdbGamePlatform = {
id: string;
name: string;
};

type IgdbGameScreenshots = {
id: string;
url: string;
};
3 changes: 3 additions & 0 deletions apps/api/src/libs/ddd/aggregate-root.base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Entity } from './entity.base';

export abstract class AggregateRoot<Props> extends Entity<Props> {}
17 changes: 17 additions & 0 deletions apps/api/src/libs/ddd/entity.base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export abstract class Entity<Props> {
protected readonly _id: number;
protected props: Props;

constructor(props: Props, id?: number) {
this._id = id ? id : 0;
this.props = props;
}

get id(): number {
return this._id;
}

public getProps(): Props {
return { ...this.props };
}
}
3 changes: 3 additions & 0 deletions apps/api/src/libs/ddd/repository.port.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface RepositoryPort<Entity> {
save(entity: Entity): Promise<Entity>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateUserCommand } from './create_user.command';
import { Inject } from '@nestjs/common';
import {
AUTH_REPOSITORY,
AuthRepositoryPort,
} from '../../../domain/ports/auth.repository.port';
import { AuthUser, UserRole } from '../../../domain/models/auth-user.model';

@CommandHandler(CreateUserCommand)
export class CreateUserCommandHandler
implements ICommandHandler<CreateUserCommand>
{
constructor(
@Inject(AUTH_REPOSITORY)
private readonly authRepository: AuthRepositoryPort,
) {}

async execute(command: CreateUserCommand) {
const newUser = AuthUser.create({
oauthId: command.oauthId,
role: UserRole.USER,
});

const savedUser = await this.authRepository.save(newUser);

return {
id: savedUser.id,
role: savedUser.role,
};
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetUserRoleQuery } from './get_user_role.query';
import { PrismaService } from '../../../database/prisma.service';
import { PrismaService } from '../../../../database/prisma.service';

@QueryHandler(GetUserRoleQuery)
export class GetUserRoleQueryHandler
Expand Down
23 changes: 17 additions & 6 deletions apps/api/src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { OAuthJwtStrategy } from './strategies/o-auth-jwt-strategy.service';
import { AuthResolver } from './auth.resolver';
import { OAuthJwtStrategy } from './infrastructure/strategies/o-auth-jwt-strategy.service';
import { AuthResolver } from './infrastructure/graphql/auth.resolver';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthService } from './infrastructure/services/auth.service';
import { DatabaseModule } from '../database/database.module';
import { BullModule } from '@nestjs/bull';
import { HttpModule } from '@nestjs/axios';
import { CqrsModule } from '@nestjs/cqrs';
import { GetUserRoleQueryHandler } from './queries/get_user_role/get_user_role.handler';
import { CreateUserCommandHandler } from './commands/create_user/create_user.handler';
import { GetUserRoleQueryHandler } from './application/queries/get_user_role/get_user_role.handler';
import { CreateUserCommandHandler } from './application/commands/create_user/create_user.handler';
import { AUTH_REPOSITORY } from './domain/ports/auth.repository.port';
import { PrismaAuthRepository } from './infrastructure/adapters/prisma-auth.repository';

const handlers = [GetUserRoleQueryHandler, CreateUserCommandHandler];

Expand All @@ -26,7 +28,16 @@ const handlers = [GetUserRoleQueryHandler, CreateUserCommandHandler];
inject: [ConfigService],
}),
],
providers: [OAuthJwtStrategy, AuthResolver, AuthService, ...handlers],
providers: [
OAuthJwtStrategy,
AuthResolver,
AuthService,
...handlers,
{
provide: AUTH_REPOSITORY,
useClass: PrismaAuthRepository,
},
],
exports: [PassportModule, AuthService],
})
export class AuthModule {}

This file was deleted.

Loading