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