diff --git a/README.md b/README.md
index 9643bd5..bacf5b9 100644
--- a/README.md
+++ b/README.md
@@ -1,37 +1,101 @@
# Michess
-A modern chess application built with React, Next.js, and TypeScript. Features a interactive chessboard with move validation and game state management.
+A modern chess gameplay platform built with React, Vite, and TypeScript. Features matchmaking, game lobby, bot opponents, player game history, chess rating, anonymous games (without account), oauth and email login.
## Screenshots
-
-

+
## Development setup
-1. Install dependencies:
+Install dependencies:
+
+```bash
+pnpm install
+```
+
+### Frontend
+
+1. Start the development server:
```bash
- pnpm install
+ pnpm nx serve web-chess
```
-2. Start the development server:
+2. Open your browser and navigate to `http://localhost:4200`
+
+### Backend
+
+1. Setup docker
```bash
- pnpm nx serve web-chess
+ brew install docker docker-compose colima
+ colima start
```
-3. Open your browser and navigate to `http://localhost:4200`
+2. Setup your `apps/node-chess/.env.local` based on the example file `apps/node-chess/.env.example`.
+
+3. Start databases
+
+ `pnpm start:db`
+
+4. Run migrations
+
+ `pnpm migration:run`
+
+5. Start backend service
+
+ `pnpm start:node`
## Project Structure
-This is an Nx monorepo with the following key libraries:
+This is an Nx monorepo with a clean separation between frontend, backend, and shared libraries.
-- `core-game` - Chess game logic and move generation
-- `react-chessboard` - React chessboard UI components
-- `web-chess` - Main Next.js application
+### Applications
+
+- **`apps/web-chess`** - Main web application
+ - Vite
+ - React
+ - Tanstack Router/Query
+ - Radix-UI
+- **`apps/node-chess`** - Main backend application
+ - NodeJS
+ - Hono
+ - Socket.IO
+ - PostgreSQL with Drizzle ORM
+ - Redis
+ - BullMQ
+
+### Core Libraries
+
+- **`libs/core-board`** - Board representation and move generation
+- **`libs/core-game`** - Chess game logic and validation
+- **`libs/core-rating`** - Glicko 2 rating system for player rankings
+
+### UI Libraries
+
+- **`libs/react-chessboard`** - Interactive chessboard React component
+ - Drag-and-drop piece movement
+ - Controlled and uncontrolled usage.
+ - Move highlighting
+ - Promotion dialog
+- **`libs/react-dnd`** - Reusable React drag-and-drop utilities
+
+### Backend Libraries
+
+- **`libs/api-*`** - Main APIs that orchestrates and interacts with all parts of the application.
+- **`libs/infra-*`** - Repositories and services that interact with data sources.
+- **`libs/be-*`** - Backend specific generic library
+
+### Other
+
+- **`libs/api-schema`** - Validation logic for backend APIs, but also contains the DTOs used on FE-
+- **`libs/common-*`** - Any common logic.
## Development
@@ -55,5 +119,13 @@ pnpm nx build web-chess
### Type Checking
```bash
-pnpm run typecheck
+pnpm typecheck
+```
+
+### Scripts
+
+#### Performance test of chess move generation
+
+```bash
+pnpm perft
```
diff --git a/apps/node-chess/src/config/service/AppConfigService.ts b/apps/node-chess/src/config/service/AppConfigService.ts
index 1a8f4b3..fd831f5 100644
--- a/apps/node-chess/src/config/service/AppConfigService.ts
+++ b/apps/node-chess/src/config/service/AppConfigService.ts
@@ -58,6 +58,8 @@ const getConfig = (): AppConfig => {
},
llm: {
geminiApiKey: readEnvStrict('GEMINI_API_KEY'),
+ openAiApiKey: readEnvStrict('OPENAI_API_KEY'),
+ deepSeekApiKey: readEnvStrict('DEEPSEEK_API_KEY'),
},
};
};
diff --git a/apps/web-chess/src/app/features/play/components/OpponentTypeSelector.tsx b/apps/web-chess/src/app/features/play/components/OpponentTypeSelector.tsx
index 4e77fc4..96781d4 100644
--- a/apps/web-chess/src/app/features/play/components/OpponentTypeSelector.tsx
+++ b/apps/web-chess/src/app/features/play/components/OpponentTypeSelector.tsx
@@ -19,6 +19,7 @@ export const OpponentTypeSelector = ({ value = 'random', onChange }: Props) => {
onValueChange={(val) => onChange?.(val as OpponentType)}
columns="2"
gap="2"
+ color={'gray'}
>
diff --git a/apps/web-chess/src/app/features/player-games-overview/PlayerGamesOverview.tsx b/apps/web-chess/src/app/features/player-games-overview/PlayerGamesOverview.tsx
index a0459f6..bb0639f 100644
--- a/apps/web-chess/src/app/features/player-games-overview/PlayerGamesOverview.tsx
+++ b/apps/web-chess/src/app/features/player-games-overview/PlayerGamesOverview.tsx
@@ -40,12 +40,14 @@ export const PlayerGamesOverview: React.FC = ({ onJoinGame }) => {
ongoingGamesPage?.items.length === 0 &&
!ongoingQueryError;
+ const defaultTab = isNoOngoingGames ? 'completed' : 'ongoing';
+
// Check if there are any ongoing games where it's the player's turn
const hasPlayerTurnGames =
ongoingGamesPage?.items.some((game) => game.turn === game.ownSide) ?? false;
return (
-
+
My Games
diff --git a/apps/web-chess/src/app/pages/__tests__/HomePage.spec.tsx b/apps/web-chess/src/app/pages/__tests__/HomePage.spec.tsx
new file mode 100644
index 0000000..826e3b2
--- /dev/null
+++ b/apps/web-chess/src/app/pages/__tests__/HomePage.spec.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '../../../test/utils/custom-testing-library';
+import { HomePage } from '../HomePage';
+
+describe('HomePage', () => {
+ it('should render all main sections without loading spinners', async () => {
+ render();
+
+ // Check that main card headers are present
+ expect(
+ await screen.findByRole('heading', { name: 'Play' }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Lobby' })).toBeInTheDocument();
+ expect(
+ screen.getByRole('heading', { name: 'My Games' }),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Active Players')).toBeInTheDocument();
+
+ // Verify no loading spinners are present
+ expect(screen.queryByRole('status')).toBeFalsy();
+ });
+
+ it('should render privacy policy link in footer', async () => {
+ render();
+
+ const privacyLink = await screen.findByText('Privacy Policy');
+ expect(privacyLink).toBeInTheDocument();
+ expect(privacyLink.closest('a')).toHaveAttribute('href', '/privacy-policy');
+ });
+});
diff --git a/apps/web-chess/src/app/pages/__tests__/RequestResetPasswordPage.spec.tsx b/apps/web-chess/src/app/pages/__tests__/RequestResetPasswordPage.spec.tsx
new file mode 100644
index 0000000..6260d32
--- /dev/null
+++ b/apps/web-chess/src/app/pages/__tests__/RequestResetPasswordPage.spec.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '../../../test/utils/custom-testing-library';
+import { RequestResetPasswordPage } from '../RequestResetPasswordPage';
+
+describe('RequestResetPasswordPage', () => {
+ it('should render the request reset password form', async () => {
+ render();
+
+ expect(await screen.findByText('Reset Password')).toBeInTheDocument();
+ expect(
+ screen.getByText('Enter your email to reset your password'),
+ ).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
+ });
+
+ it('should render sign in link', async () => {
+ render();
+
+ const signInLink = await screen.findByText('Sign in');
+ expect(signInLink).toBeInTheDocument();
+ expect(signInLink.closest('a')).toHaveAttribute('href', '/sign-in');
+ });
+
+ it('should render OR separator', async () => {
+ render();
+
+ expect(await screen.findByText('OR')).toBeInTheDocument();
+ });
+});
diff --git a/apps/web-chess/src/app/pages/__tests__/ResetPasswordPage.spec.tsx b/apps/web-chess/src/app/pages/__tests__/ResetPasswordPage.spec.tsx
new file mode 100644
index 0000000..5711adf
--- /dev/null
+++ b/apps/web-chess/src/app/pages/__tests__/ResetPasswordPage.spec.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from '../../../test/utils/custom-testing-library';
+import { ResetPasswordPage } from '../ResetPasswordPage';
+
+describe('ResetPasswordPage', () => {
+ it('should render the reset password form when token is provided', async () => {
+ render();
+
+ expect(await screen.findByText('Reset Password')).toBeInTheDocument();
+ expect(
+ screen.getByText('Complete the form to reset your password'),
+ ).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Confirm Password')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
+ });
+
+ it('should show error message when token is not provided', async () => {
+ render();
+
+ expect(
+ await screen.findByText(
+ 'Something went wrong. Please try resetting your password again.',
+ ),
+ ).toBeInTheDocument();
+ expect(screen.queryByPlaceholderText('Password')).toBeFalsy();
+ });
+
+ it('should render sign in link', async () => {
+ render();
+
+ const signInLink = await screen.findByText('Sign in');
+ expect(signInLink).toBeInTheDocument();
+ expect(signInLink.closest('a')).toHaveAttribute('href', '/sign-in');
+ });
+});
diff --git a/apps/web-chess/src/app/pages/__tests__/SignInPage.spec.tsx b/apps/web-chess/src/app/pages/__tests__/SignInPage.spec.tsx
new file mode 100644
index 0000000..93a1f73
--- /dev/null
+++ b/apps/web-chess/src/app/pages/__tests__/SignInPage.spec.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from '../../../test/utils/custom-testing-library';
+import { SignInPage } from '../SignInPage';
+
+describe('SignInPage', () => {
+ it('should render the sign in form', async () => {
+ render();
+
+ expect(await screen.findByText('Sign in')).toBeTruthy();
+ expect(
+ screen.getByText('Welcome back! Please enter your details.'),
+ ).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /sign in/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('should show success message when referer is reset-password', async () => {
+ render();
+
+ expect(
+ await screen.findByText(
+ 'Your password has been reset successfully. You can now login.',
+ ),
+ ).toBeTruthy();
+ expect(
+ screen.queryByText('Welcome back! Please enter your details.'),
+ ).toBeFalsy();
+ });
+
+ it('should render sign up link', async () => {
+ render();
+
+ const signUpLink = await screen.findByText('Sign up');
+ expect(signUpLink).toBeInTheDocument();
+ expect(signUpLink.closest('a')).toHaveAttribute('href', '/sign-up');
+ });
+});
diff --git a/apps/web-chess/src/app/pages/__tests__/SignUpPage.spec.tsx b/apps/web-chess/src/app/pages/__tests__/SignUpPage.spec.tsx
new file mode 100644
index 0000000..f8dbb02
--- /dev/null
+++ b/apps/web-chess/src/app/pages/__tests__/SignUpPage.spec.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from '../../../test/utils/custom-testing-library';
+import { SignUpPage } from '../SignUpPage';
+
+describe('SignUpPage', () => {
+ it('should render the sign up form', async () => {
+ render();
+
+ expect(await screen.findByText('Sign up')).toBeInTheDocument();
+ expect(
+ screen.getByText('Create your account to get started'),
+ ).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Name')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Username')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Confirm Password')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /create account/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('should render social sign in option', async () => {
+ render();
+
+ expect(
+ await screen.findByRole('button', { name: /continue with google/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('should render sign in link', async () => {
+ render();
+
+ const signInLink = await screen.findByText('Sign in');
+ expect(signInLink).toBeInTheDocument();
+ expect(signInLink.closest('a')).toHaveAttribute('href', '/sign-in');
+ });
+});
diff --git a/apps/web-chess/src/app/pages/__tests__/WelcomePage.spec.tsx b/apps/web-chess/src/app/pages/__tests__/WelcomePage.spec.tsx
new file mode 100644
index 0000000..b22ca42
--- /dev/null
+++ b/apps/web-chess/src/app/pages/__tests__/WelcomePage.spec.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from '../../../test/utils/custom-testing-library';
+import { WelcomePage } from '../WelcomePage';
+
+describe('WelcomePage', () => {
+ it('should render welcome message with user name', async () => {
+ render();
+
+ expect(
+ await screen.findByText('Welcome to Chessmonky Test User!'),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText('Complete the steps below or skip for now'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Start playing')).toBeInTheDocument();
+ });
+
+ it('should render username form when type is social', async () => {
+ render();
+
+ expect(
+ await screen.findByText('Welcome to Chessmonky Test User!'),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText('Username')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
+ });
+
+ it('should render email verification message when type is email', async () => {
+ render();
+
+ expect(
+ await screen.findByText('Welcome to Chessmonky Test User!'),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/We sent a verification link/i),
+ ).toBeInTheDocument();
+ });
+
+ it('should render start playing link', async () => {
+ render();
+
+ const startLink = await screen.findByText('Start playing');
+ expect(startLink).toBeInTheDocument();
+ expect(startLink.closest('a')).toHaveAttribute('href', '/');
+ });
+});
diff --git a/libs/api-service/src/lib/llm/client/DeepSeekClient.ts b/libs/api-service/src/lib/llm/client/DeepSeekClient.ts
new file mode 100644
index 0000000..91e3e4d
--- /dev/null
+++ b/libs/api-service/src/lib/llm/client/DeepSeekClient.ts
@@ -0,0 +1,63 @@
+import { logger } from '@michess/be-utils';
+import OpenAI from 'openai';
+import { LlmResponse } from '../model/LlmMessage';
+import { LlmRequest } from '../model/LlmRequest';
+import { LlmClient } from './LlmClient';
+
+export class DeepSeekClient implements LlmClient {
+ private client: OpenAI;
+
+ constructor(
+ apiKey: string,
+ private readonly modelName: string,
+ ) {
+ this.client = new OpenAI({
+ apiKey,
+ baseURL: 'https://api.deepseek.com',
+ });
+ }
+
+ async generateResponse(request: LlmRequest): Promise {
+ try {
+ const completion = await this.client.chat.completions.create({
+ model: this.modelName,
+ messages: [
+ { role: 'system', content: request.systemPrompt },
+ { role: 'user', content: request.userPrompt },
+ ],
+ temperature: request.temperature,
+ });
+
+ const choice = completion.choices[0];
+ const text = choice?.message?.content ?? '';
+ const finishReason = choice?.finish_reason ?? 'unknown';
+
+ logger.debug(
+ {
+ modelName: this.modelName,
+ promptLength: request.userPrompt.length,
+ responseLength: text.length,
+ },
+ 'DeepSeek API call successful',
+ );
+
+ return {
+ content: text,
+ finishReason: finishReason === 'stop' ? 'stop' : 'error',
+ };
+ } catch (error) {
+ logger.error(
+ {
+ err: error,
+ modelName: this.modelName,
+ },
+ 'DeepSeek API call failed',
+ );
+
+ return {
+ content: '',
+ finishReason: 'error',
+ };
+ }
+ }
+}
diff --git a/libs/api-service/src/lib/llm/client/OpenAiClient.ts b/libs/api-service/src/lib/llm/client/OpenAiClient.ts
new file mode 100644
index 0000000..aac574d
--- /dev/null
+++ b/libs/api-service/src/lib/llm/client/OpenAiClient.ts
@@ -0,0 +1,60 @@
+import { logger } from '@michess/be-utils';
+import OpenAI from 'openai';
+import { LlmResponse } from '../model/LlmMessage';
+import { LlmRequest } from '../model/LlmRequest';
+import { LlmClient } from './LlmClient';
+
+export class OpenAiClient implements LlmClient {
+ private client: OpenAI;
+
+ constructor(
+ apiKey: string,
+ private readonly modelName: string,
+ ) {
+ this.client = new OpenAI({ apiKey });
+ }
+
+ async generateResponse(request: LlmRequest): Promise {
+ try {
+ const completion = await this.client.chat.completions.create({
+ model: this.modelName,
+ messages: [
+ { role: 'system', content: request.systemPrompt },
+ { role: 'user', content: request.userPrompt },
+ ],
+ temperature: request.temperature,
+ });
+
+ const choice = completion.choices[0];
+ const text = choice?.message?.content ?? '';
+ const finishReason = choice?.finish_reason ?? 'unknown';
+
+ logger.debug(
+ {
+ modelName: this.modelName,
+ promptLength: request.userPrompt.length,
+ responseLength: text.length,
+ },
+ 'OpenAI API call successful',
+ );
+
+ return {
+ content: text,
+ finishReason: finishReason === 'stop' ? 'stop' : 'error',
+ };
+ } catch (error) {
+ logger.error(
+ {
+ err: error,
+ modelName: this.modelName,
+ },
+ 'OpenAI API call failed',
+ );
+
+ return {
+ content: '',
+ finishReason: 'error',
+ };
+ }
+ }
+}
diff --git a/libs/api-service/src/lib/llm/config/LlmConfig.ts b/libs/api-service/src/lib/llm/config/LlmConfig.ts
index 2c0f6b4..92cd641 100644
--- a/libs/api-service/src/lib/llm/config/LlmConfig.ts
+++ b/libs/api-service/src/lib/llm/config/LlmConfig.ts
@@ -1,3 +1,5 @@
export type LlmConfig = {
geminiApiKey: string;
+ openAiApiKey: string;
+ deepSeekApiKey: string;
};
diff --git a/libs/api-service/src/lib/llm/service/LlmClientFactory.ts b/libs/api-service/src/lib/llm/service/LlmClientFactory.ts
index 49f2ec7..9c6723b 100644
--- a/libs/api-service/src/lib/llm/service/LlmClientFactory.ts
+++ b/libs/api-service/src/lib/llm/service/LlmClientFactory.ts
@@ -1,20 +1,26 @@
import { logger } from '@michess/be-utils';
import { BotConfig } from '../../user/config/model/BotConfig';
+import { DeepSeekClient } from '../client/DeepSeekClient';
import { GeminiClient } from '../client/GeminiClient';
import { LlmClient } from '../client/LlmClient';
+import { OpenAiClient } from '../client/OpenAiClient';
+import { LlmConfig } from '../config/LlmConfig';
export class LlmClientFactory {
- static create(botConfig: BotConfig, apiKey: string): LlmClient {
+ static create(botConfig: BotConfig, config: LlmConfig): LlmClient {
switch (botConfig.provider) {
case 'gemini':
- return new GeminiClient(apiKey, botConfig.model);
- case 'claude':
+ return new GeminiClient(config.geminiApiKey, botConfig.model);
case 'gpt':
+ return new OpenAiClient(config.openAiApiKey, botConfig.model);
+ case 'deepseek':
+ return new DeepSeekClient(config.deepSeekApiKey, botConfig.model);
+ case 'claude':
logger.warn(
{ provider: botConfig.provider },
'LLM provider not yet implemented, falling back to Gemini',
);
- return new GeminiClient(apiKey, botConfig.model);
+ return new GeminiClient(config.geminiApiKey, botConfig.model);
default:
throw new Error(`Unknown LLM provider: ${botConfig.provider}`);
}
diff --git a/libs/api-service/src/lib/user/config/BotRegistry.ts b/libs/api-service/src/lib/user/config/BotRegistry.ts
index 5c72998..6e697ba 100644
--- a/libs/api-service/src/lib/user/config/BotRegistry.ts
+++ b/libs/api-service/src/lib/user/config/BotRegistry.ts
@@ -13,6 +13,28 @@ const BOT_REGISTRY: Record = {
'You play balanced, solid chess. Consider both tactical and positional factors.',
temperature: 0.7,
},
+ 'bot-gpt-1': {
+ id: 'bot-gpt-1',
+ name: 'ChatGPT 1',
+ username: 'gpt-1',
+ description: 'A balanced AI chess player powered by ChatGPT',
+ provider: 'gpt',
+ model: 'gpt-4o-mini',
+ personality:
+ 'You play balanced, solid chess. Consider both tactical and positional factors.',
+ temperature: 0.7,
+ },
+ 'bot-deepseek-1': {
+ id: 'bot-deepseek-1',
+ name: 'DeepSeek 1',
+ username: 'deepseek-1',
+ description: 'A balanced AI chess player powered by DeepSeek',
+ provider: 'deepseek',
+ model: 'deepseek-chat',
+ personality:
+ 'You play balanced, solid chess. Consider both tactical and positional factors.',
+ temperature: 0.7,
+ },
};
const getBotConfig = (botId: string): Maybe => {
diff --git a/libs/api-service/src/lib/user/config/model/BotConfig.ts b/libs/api-service/src/lib/user/config/model/BotConfig.ts
index dfcbc40..003e468 100644
--- a/libs/api-service/src/lib/user/config/model/BotConfig.ts
+++ b/libs/api-service/src/lib/user/config/model/BotConfig.ts
@@ -3,7 +3,7 @@ export type BotConfig = {
name: string;
username: string;
description: string;
- provider: 'gemini' | 'claude' | 'gpt';
+ provider: 'gemini' | 'claude' | 'gpt' | 'deepseek';
model: string;
personality: string;
temperature: number;
diff --git a/libs/api-service/src/lib/user/service/BotService.ts b/libs/api-service/src/lib/user/service/BotService.ts
index fe98356..e7d6205 100644
--- a/libs/api-service/src/lib/user/service/BotService.ts
+++ b/libs/api-service/src/lib/user/service/BotService.ts
@@ -198,29 +198,22 @@ export class BotService {
throw new Error(`Bot configuration not found for bot ${botId}`);
}
- // Load game from database
const game = await this.gameRepository.findGameWithRelationsById(gameId);
assertDefined(game, `Game ${gameId} not found`);
- // Build chessboard from game state
const moves = game.moves.map((m) => Move.fromUci(m.uci));
const initialPosition = FenParser.toChessPosition(FenStr.standardInitial());
const chessboard = Chessboard.fromPosition(initialPosition, moves);
- // Get available move options
const moveOptions = chessboard.moveOptions;
if (moveOptions.length === 0) {
throw new Error('No legal moves available');
}
- const llmClient = LlmClientFactory.create(
- botConfig,
- this.llmConfig.geminiApiKey,
- );
+ const llmClient = LlmClientFactory.create(botConfig, this.llmConfig);
- // Get FEN position for compact representation
const fen = FenParser.toFenStr(chessboard.position);
// Format available moves for the LLM
@@ -269,7 +262,6 @@ Move:`;
// Extract UCI move from response (take first word, clean it up)
const uciMove = response.content.trim().split(/\s+/)[0].toLowerCase();
- // Validate that the move is in the available moves
const isValidMove = moveOptions.some(
(opt) => Move.toUci(MoveOption.toMove(opt)) === uciMove,
);
diff --git a/package.json b/package.json
index 507aa5e..4cf6278 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,6 @@
"bullmq": "^5.63.0",
"core-js": "^3.46.0",
"dotenv": "^17.2.3",
- "dotenv-cli": "^10.0.0",
"drizzle-orm": "^0.44.7",
"hono": "^4.10.5",
"hono-pino": "^0.10.3",
@@ -60,7 +59,8 @@
"ky": "^1.14.0",
"lucide-react": "^0.553.0",
"nodemailer": "^7.0.10",
- "pino": "^9.13.1",
+ "openai": "^6.10.0",
+ "pino": "^10.1.0",
"postgres": "^3.4.7",
"react": "19.2.0",
"react-dom": "19.2.0",
@@ -105,6 +105,7 @@
"babel-jest": "30.2.0",
"commander": "^14.0.0",
"cross-fetch": "^4.1.0",
+ "dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.5",
"esbuild": "^0.19.2",
"eslint": "9.37.0",
@@ -136,5 +137,6 @@
"typescript-plugin-css-modules": "^5.2.0",
"vite": "^7.0.0",
"vitest": "^4.0.15"
- }
+ },
+ "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 80b37bb..784f2ec 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -63,9 +63,6 @@ importers:
dotenv:
specifier: ^17.2.3
version: 17.2.3
- dotenv-cli:
- specifier: ^10.0.0
- version: 10.0.0
drizzle-orm:
specifier: ^0.44.7
version: 0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.5)(postgres@3.4.7)
@@ -74,7 +71,7 @@ importers:
version: 4.10.5
hono-pino:
specifier: ^0.10.3
- version: 0.10.3(hono@4.10.5)(pino@9.13.1)
+ version: 0.10.3(hono@4.10.5)(pino@10.1.0)
ioredis:
specifier: ^5.8.1
version: 5.8.1
@@ -87,9 +84,12 @@ importers:
nodemailer:
specifier: ^7.0.10
version: 7.0.10
+ openai:
+ specifier: ^6.10.0
+ version: 6.10.0(ws@8.18.0)(zod@4.1.12)
pino:
- specifier: ^9.13.1
- version: 9.13.1
+ specifier: ^10.1.0
+ version: 10.1.0
postgres:
specifier: ^3.4.7
version: 3.4.7
@@ -217,6 +217,9 @@ importers:
cross-fetch:
specifier: ^4.1.0
version: 4.1.0(encoding@0.1.13)
+ dotenv-cli:
+ specifier: ^11.0.0
+ version: 11.0.0
drizzle-kit:
specifier: ^0.31.5
version: 0.31.5
@@ -2852,6 +2855,9 @@ packages:
peerDependencies:
typescript: ^3 || ^4 || ^5
+ '@pinojs/redact@0.4.0':
+ resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
+
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -4327,28 +4333,24 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@swc/core-linux-arm64-musl@1.5.29':
resolution: {integrity: sha512-TERh2OICAJz+SdDIK9+0GyTUwF6r4xDlFmpoiHKHrrD/Hh3u+6Zue0d7jQ/he/i80GDn4tJQkHlZys+RZL5UZg==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@swc/core-linux-x64-gnu@1.5.29':
resolution: {integrity: sha512-WMDPqU7Ji9dJpA+Llek2p9t7pcy7Bob8ggPUvgsIlv3R/eesF9DIzSbrgl6j3EAEPB9LFdSafsgf6kT/qnvqFg==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@swc/core-linux-x64-musl@1.5.29':
resolution: {integrity: sha512-DO14glwpdKY4POSN0201OnGg1+ziaSVr6/RFzuSLggshwXeeyVORiHv3baj7NENhJhWhUy3NZlDsXLnRFkmhHQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@swc/core-win32-arm64-msvc@1.5.29':
resolution: {integrity: sha512-V3Y1+a1zG1zpYXUMqPIHEMEOd+rHoVnIpO/KTyFwAmKVu8v+/xPEVx/AGoYE67x4vDAAvPQrKI3Aokilqa5yVg==}
@@ -5831,14 +5833,18 @@ packages:
dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
- dotenv-cli@10.0.0:
- resolution: {integrity: sha512-lnOnttzfrzkRx2echxJHQRB6vOAMSCzzZg79IxpC00tU42wZPuZkQxNNrrwVAxaQZIIh001l4PxVlCrBxngBzA==}
+ dotenv-cli@11.0.0:
+ resolution: {integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==}
hasBin: true
dotenv-expand@11.0.7:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
engines: {node: '>=12'}
+ dotenv-expand@12.0.3:
+ resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==}
+ engines: {node: '>=12'}
+
dotenv@16.4.7:
resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
engines: {node: '>=12'}
@@ -7809,6 +7815,18 @@ packages:
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
engines: {node: '>=12'}
+ openai@6.10.0:
+ resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==}
+ hasBin: true
+ peerDependencies:
+ ws: ^8.18.0
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ ws:
+ optional: true
+ zod:
+ optional: true
+
opener@1.5.2:
resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
hasBin: true
@@ -7980,8 +7998,8 @@ packages:
pino-std-serializers@7.0.0:
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
- pino@9.13.1:
- resolution: {integrity: sha512-Szuj+ViDTjKPQYiKumGmEn3frdl+ZPSdosHyt9SnUevFosOkMY2b7ipxlEctNKPmMD/VibeBI+ZcZCJK+4DPuw==}
+ pino@10.1.0:
+ resolution: {integrity: sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==}
hasBin: true
pirates@4.0.7:
@@ -8850,9 +8868,6 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
- slow-redact@0.3.2:
- resolution: {integrity: sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw==}
-
snake-case@3.0.4:
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
@@ -13287,6 +13302,8 @@ snapshots:
esquery: 1.6.0
typescript: 5.9.3
+ '@pinojs/redact@0.4.0': {}
+
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -16466,17 +16483,21 @@ snapshots:
no-case: 3.0.4
tslib: 2.8.1
- dotenv-cli@10.0.0:
+ dotenv-cli@11.0.0:
dependencies:
cross-spawn: 7.0.6
dotenv: 17.2.3
- dotenv-expand: 11.0.7
+ dotenv-expand: 12.0.3
minimist: 1.2.8
dotenv-expand@11.0.7:
dependencies:
dotenv: 16.4.7
+ dotenv-expand@12.0.3:
+ dependencies:
+ dotenv: 16.4.7
+
dotenv@16.4.7: {}
dotenv@17.2.3: {}
@@ -17503,11 +17524,11 @@ snapshots:
dependencies:
parse-passwd: 1.0.0
- hono-pino@0.10.3(hono@4.10.5)(pino@9.13.1):
+ hono-pino@0.10.3(hono@4.10.5)(pino@10.1.0):
dependencies:
defu: 6.1.4
hono: 4.10.5
- pino: 9.13.1
+ pino: 10.1.0
hono@4.10.5: {}
@@ -18992,6 +19013,11 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
+ openai@6.10.0(ws@8.18.0)(zod@4.1.12):
+ optionalDependencies:
+ ws: 8.18.0
+ zod: 4.1.12
+
opener@1.5.2: {}
optionator@0.9.4:
@@ -19162,8 +19188,9 @@ snapshots:
pino-std-serializers@7.0.0: {}
- pino@9.13.1:
+ pino@10.1.0:
dependencies:
+ '@pinojs/redact': 0.4.0
atomic-sleep: 1.0.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 2.0.0
@@ -19172,7 +19199,6 @@ snapshots:
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.5.0
- slow-redact: 0.3.2
sonic-boom: 4.2.0
thread-stream: 3.1.0
@@ -20109,8 +20135,6 @@ snapshots:
slash@3.0.0: {}
- slow-redact@0.3.2: {}
-
snake-case@3.0.4:
dependencies:
dot-case: 3.0.4
diff --git a/screenshots/home-page.png b/screenshots/home-page.png
new file mode 100644
index 0000000..c35bb72
Binary files /dev/null and b/screenshots/home-page.png differ